diff --git a/crates/ruff_python_parser/resources/inline/err/parenthesized_context_manager_py38.py b/crates/ruff_python_parser/resources/inline/err/parenthesized_context_manager_py38.py new file mode 100644 index 00000000000000..f088d23ba57060 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/parenthesized_context_manager_py38.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.8"} +with (foo as x, bar as y): ... diff --git a/crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py38.py b/crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py38.py new file mode 100644 index 00000000000000..8ee0c2824a345d --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py38.py @@ -0,0 +1,14 @@ +# parse_options: {"target-version": "3.8"} +with (foo, bar): ... +with ( + open('foo.txt')) as foo: ... +with ( + foo, + bar, + baz, +): ... +with ( + foo, + bar, + baz, +) as tup: ... diff --git a/crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py39.py b/crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py39.py new file mode 100644 index 00000000000000..41c8040103cb3b --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py39.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.9"} +with (foo as x, bar as y): ... diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 1e8934d3d12358..dc3831f2883513 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -610,6 +610,46 @@ pub enum UnsupportedSyntaxErrorKind { TypeParameterList, TypeAliasStatement, TypeParamDefault, + + /// Represents the use of a parenthesized `with` item before Python 3.9. + /// + /// ## Examples + /// + /// As described in [BPO 12782], `with` uses like this were not allowed on Python 3.8: + /// + /// ```python + /// with (open("a_really_long_foo") as foo, + /// open("a_really_long_bar") as bar): + /// pass + /// ``` + /// + /// because parentheses were not allowed within the `with` statement itself (see [this comment] + /// in particular). However, parenthesized expressions were still allowed, including the cases + /// below, so the issue can be pretty subtle and relates specifically to parenthesized items + /// with `as` bindings. + /// + /// ```python + /// with (foo, bar): ... # okay + /// with ( + /// open('foo.txt')) as foo: ... # also okay + /// with ( + /// foo, + /// bar, + /// baz, + /// ): ... # also okay, just a tuple + /// with ( + /// foo, + /// bar, + /// baz, + /// ) as tup: ... # also okay, binding the tuple + /// ``` + /// + /// This restriction was lifted in 3.9 but formally included in the [release notes] for 3.10. + /// + /// [BPO 12782]: https://github.com/python/cpython/issues/56991 + /// [this comment]: https://github.com/python/cpython/issues/56991#issuecomment-1093555141 + /// [release notes]: https://docs.python.org/3/whatsnew/3.10.html#summary-release-highlights + ParenthesizedContextManager, } impl Display for UnsupportedSyntaxError { @@ -636,6 +676,9 @@ impl Display for UnsupportedSyntaxError { UnsupportedSyntaxErrorKind::TypeParamDefault => { "Cannot set default type for a type parameter" } + UnsupportedSyntaxErrorKind::ParenthesizedContextManager => { + "Cannot use parentheses within a `with` statement" + } }; write!( @@ -681,6 +724,9 @@ impl UnsupportedSyntaxErrorKind { UnsupportedSyntaxErrorKind::TypeParameterList => Change::Added(PythonVersion::PY312), UnsupportedSyntaxErrorKind::TypeAliasStatement => Change::Added(PythonVersion::PY312), UnsupportedSyntaxErrorKind::TypeParamDefault => Change::Added(PythonVersion::PY313), + UnsupportedSyntaxErrorKind::ParenthesizedContextManager => { + Change::Added(PythonVersion::PY39) + } } } diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index f9bde0aa0407c5..efa663f894c607 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -2039,8 +2039,39 @@ impl<'src> Parser<'src> { return vec![]; } + let open_paren_range = self.current_token_range(); + if self.at(TokenKind::Lpar) { if let Some(items) = self.try_parse_parenthesized_with_items() { + if items.iter().any(|item| item.optional_vars.is_some()) { + // test_ok parenthesized_context_manager_py38 + // # parse_options: {"target-version": "3.8"} + // with (foo, bar): ... + // with ( + // open('foo.txt')) as foo: ... + // with ( + // foo, + // bar, + // baz, + // ): ... + // with ( + // foo, + // bar, + // baz, + // ) as tup: ... + + // test_ok parenthesized_context_manager_py39 + // # parse_options: {"target-version": "3.9"} + // with (foo as x, bar as y): ... + + // test_err parenthesized_context_manager_py38 + // # parse_options: {"target-version": "3.8"} + // with (foo as x, bar as y): ... + self.add_unsupported_syntax_error( + UnsupportedSyntaxErrorKind::ParenthesizedContextManager, + open_paren_range, + ); + } self.expect(TokenKind::Rpar); items } else { diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_context_manager_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_context_manager_py38.py.snap new file mode 100644 index 00000000000000..8b7849a842bba1 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_context_manager_py38.py.snap @@ -0,0 +1,80 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/parenthesized_context_manager_py38.py +--- +## AST + +``` +Module( + ModModule { + range: 0..74, + body: [ + With( + StmtWith { + range: 43..73, + is_async: false, + items: [ + WithItem { + range: 49..57, + context_expr: Name( + ExprName { + range: 49..52, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 56..57, + id: Name("x"), + ctx: Store, + }, + ), + ), + }, + WithItem { + range: 59..67, + context_expr: Name( + ExprName { + range: 59..62, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 66..67, + id: Name("y"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + range: 70..73, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 70..73, + }, + ), + }, + ), + ], + }, + ), + ], + }, +) +``` +## Unsupported Syntax Errors + + | +1 | # parse_options: {"target-version": "3.8"} +2 | with (foo as x, bar as y): ... + | ^ Syntax Error: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) + | diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_context_manager_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_context_manager_py38.py.snap new file mode 100644 index 00000000000000..de63cf81584392 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_context_manager_py38.py.snap @@ -0,0 +1,240 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py38.py +--- +## AST + +``` +Module( + ModModule { + range: 0..179, + body: [ + With( + StmtWith { + range: 43..63, + is_async: false, + items: [ + WithItem { + range: 49..52, + context_expr: Name( + ExprName { + range: 49..52, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: None, + }, + WithItem { + range: 54..57, + context_expr: Name( + ExprName { + range: 54..57, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: None, + }, + ], + body: [ + Expr( + StmtExpr { + range: 60..63, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 60..63, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 64..101, + is_async: false, + items: [ + WithItem { + range: 69..96, + context_expr: Call( + ExprCall { + range: 73..88, + func: Name( + ExprName { + range: 73..77, + id: Name("open"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 77..88, + args: [ + StringLiteral( + ExprStringLiteral { + range: 78..87, + value: StringLiteralValue { + inner: Single( + StringLiteral { + range: 78..87, + value: "foo.txt", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + }, + }, + ), + }, + }, + ), + ], + keywords: [], + }, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 93..96, + id: Name("foo"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + range: 98..101, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 98..101, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 102..136, + is_async: false, + items: [ + WithItem { + range: 111..114, + context_expr: Name( + ExprName { + range: 111..114, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: None, + }, + WithItem { + range: 118..121, + context_expr: Name( + ExprName { + range: 118..121, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: None, + }, + WithItem { + range: 125..128, + context_expr: Name( + ExprName { + range: 125..128, + id: Name("baz"), + ctx: Load, + }, + ), + optional_vars: None, + }, + ], + body: [ + Expr( + StmtExpr { + range: 133..136, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 133..136, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 137..178, + is_async: false, + items: [ + WithItem { + range: 142..173, + context_expr: Tuple( + ExprTuple { + range: 142..166, + elts: [ + Name( + ExprName { + range: 146..149, + id: Name("foo"), + ctx: Load, + }, + ), + Name( + ExprName { + range: 153..156, + id: Name("bar"), + ctx: Load, + }, + ), + Name( + ExprName { + range: 160..163, + id: Name("baz"), + ctx: Load, + }, + ), + ], + ctx: Load, + parenthesized: true, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 170..173, + id: Name("tup"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + range: 175..178, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 175..178, + }, + ), + }, + ), + ], + }, + ), + ], + }, +) +``` diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_context_manager_py39.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_context_manager_py39.py.snap new file mode 100644 index 00000000000000..065b7b1cf1881d --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_context_manager_py39.py.snap @@ -0,0 +1,73 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py39.py +--- +## AST + +``` +Module( + ModModule { + range: 0..74, + body: [ + With( + StmtWith { + range: 43..73, + is_async: false, + items: [ + WithItem { + range: 49..57, + context_expr: Name( + ExprName { + range: 49..52, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 56..57, + id: Name("x"), + ctx: Store, + }, + ), + ), + }, + WithItem { + range: 59..67, + context_expr: Name( + ExprName { + range: 59..62, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 66..67, + id: Name("y"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + range: 70..73, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 70..73, + }, + ), + }, + ), + ], + }, + ), + ], + }, +) +```