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 0000000000..81065e49b1 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/parenthesized_context_manager_py38.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.8"} +with (foo as x, bar as y): ... +with (foo, bar as y): ... +with (foo as x, bar): ... diff --git a/crates/ruff_python_parser/resources/inline/err/tuple_context_manager_py38.py b/crates/ruff_python_parser/resources/inline/err/tuple_context_manager_py38.py new file mode 100644 index 0000000000..bdc0639602 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/tuple_context_manager_py38.py @@ -0,0 +1,13 @@ +# parse_options: {"target-version": "3.8"} +# these cases are _syntactically_ valid before Python 3.9 because the `with` item +# is parsed as a tuple, but this will always cause a runtime error, so we flag it +# anyway +with (foo, bar): ... +with ( + open('foo.txt')) as foo: ... +with ( + foo, + bar, + baz, +): ... +with (foo,): ... 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 0000000000..9a0e6466d8 --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/parenthesized_context_manager_py39.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.9"} +with (foo as x, bar as y): ... +with (foo, bar as y): ... +with (foo as x, bar): ... diff --git a/crates/ruff_python_parser/resources/inline/ok/tuple_context_manager_py38.py b/crates/ruff_python_parser/resources/inline/ok/tuple_context_manager_py38.py new file mode 100644 index 0000000000..c93d3c7e9a --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/tuple_context_manager_py38.py @@ -0,0 +1,6 @@ +# parse_options: {"target-version": "3.8"} +with ( + foo, + bar, + baz, +) as tup: ... diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 815e2181f5..46eada457d 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -661,6 +661,46 @@ pub enum UnsupportedSyntaxErrorKind { 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, + /// Represents the use of a [PEP 646] star expression in an index. /// /// ## Examples @@ -798,6 +838,9 @@ impl Display for UnsupportedSyntaxError { UnsupportedSyntaxErrorKind::TypeParamDefault => { "Cannot set default type for a type parameter" } + UnsupportedSyntaxErrorKind::ParenthesizedContextManager => { + "Cannot use parentheses within a `with` statement" + } UnsupportedSyntaxErrorKind::StarExpressionInIndex => { "Cannot use star expression in index" } @@ -861,6 +904,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) + } UnsupportedSyntaxErrorKind::StarExpressionInIndex => { Change::Added(PythonVersion::PY311) } diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 2b357c853f..8cff331cb5 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -2066,8 +2066,49 @@ 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() { + // test_ok tuple_context_manager_py38 + // # parse_options: {"target-version": "3.8"} + // with ( + // foo, + // bar, + // baz, + // ) as tup: ... + + // test_err tuple_context_manager_py38 + // # parse_options: {"target-version": "3.8"} + // # these cases are _syntactically_ valid before Python 3.9 because the `with` item + // # is parsed as a tuple, but this will always cause a runtime error, so we flag it + // # anyway + // with (foo, bar): ... + // with ( + // open('foo.txt')) as foo: ... + // with ( + // foo, + // bar, + // baz, + // ): ... + // with (foo,): ... + + // test_ok parenthesized_context_manager_py39 + // # parse_options: {"target-version": "3.9"} + // with (foo as x, bar as y): ... + // with (foo, bar as y): ... + // with (foo as x, bar): ... + + // test_err parenthesized_context_manager_py38 + // # parse_options: {"target-version": "3.8"} + // with (foo as x, bar as y): ... + // with (foo, bar as y): ... + // with (foo as x, bar): ... + 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 0000000000..0cc6748cbb --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_context_manager_py38.py.snap @@ -0,0 +1,199 @@ +--- +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..126, + 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, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 74..99, + is_async: false, + items: [ + WithItem { + range: 80..83, + context_expr: Name( + ExprName { + range: 80..83, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: None, + }, + WithItem { + range: 85..93, + context_expr: Name( + ExprName { + range: 85..88, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 92..93, + id: Name("y"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + range: 96..99, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 96..99, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 100..125, + is_async: false, + items: [ + WithItem { + range: 106..114, + context_expr: Name( + ExprName { + range: 106..109, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 113..114, + id: Name("x"), + ctx: Store, + }, + ), + ), + }, + WithItem { + range: 116..119, + context_expr: Name( + ExprName { + range: 116..119, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: None, + }, + ], + body: [ + Expr( + StmtExpr { + range: 122..125, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 122..125, + }, + ), + }, + ), + ], + }, + ), + ], + }, +) +``` +## 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) +3 | with (foo, bar as y): ... +4 | with (foo as x, bar): ... + | + + + | +1 | # parse_options: {"target-version": "3.8"} +2 | with (foo as x, bar as y): ... +3 | with (foo, bar as y): ... + | ^ Syntax Error: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) +4 | with (foo as x, bar): ... + | + + + | +2 | with (foo as x, bar as y): ... +3 | with (foo, bar as y): ... +4 | with (foo as x, bar): ... + | ^ 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/invalid_syntax@tuple_context_manager_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@tuple_context_manager_py38.py.snap new file mode 100644 index 0000000000..4cb4a0a1bc --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@tuple_context_manager_py38.py.snap @@ -0,0 +1,237 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/tuple_context_manager_py38.py +--- +## AST + +``` +Module( + ModModule { + range: 0..327, + body: [ + With( + StmtWith { + range: 216..236, + is_async: false, + items: [ + WithItem { + range: 222..225, + context_expr: Name( + ExprName { + range: 222..225, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: None, + }, + WithItem { + range: 227..230, + context_expr: Name( + ExprName { + range: 227..230, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: None, + }, + ], + body: [ + Expr( + StmtExpr { + range: 233..236, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 233..236, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 237..274, + is_async: false, + items: [ + WithItem { + range: 242..269, + context_expr: Call( + ExprCall { + range: 246..261, + func: Name( + ExprName { + range: 246..250, + id: Name("open"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 250..261, + args: [ + StringLiteral( + ExprStringLiteral { + range: 251..260, + value: StringLiteralValue { + inner: Single( + StringLiteral { + range: 251..260, + value: "foo.txt", + flags: StringLiteralFlags { + quote_style: Single, + prefix: Empty, + triple_quoted: false, + }, + }, + ), + }, + }, + ), + ], + keywords: [], + }, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 266..269, + id: Name("foo"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + range: 271..274, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 271..274, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 275..309, + is_async: false, + items: [ + WithItem { + range: 284..287, + context_expr: Name( + ExprName { + range: 284..287, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: None, + }, + WithItem { + range: 291..294, + context_expr: Name( + ExprName { + range: 291..294, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: None, + }, + WithItem { + range: 298..301, + context_expr: Name( + ExprName { + range: 298..301, + id: Name("baz"), + ctx: Load, + }, + ), + optional_vars: None, + }, + ], + body: [ + Expr( + StmtExpr { + range: 306..309, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 306..309, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 310..326, + is_async: false, + items: [ + WithItem { + range: 316..319, + context_expr: Name( + ExprName { + range: 316..319, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: None, + }, + ], + body: [ + Expr( + StmtExpr { + range: 323..326, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 323..326, + }, + ), + }, + ), + ], + }, + ), + ], + }, +) +``` +## Unsupported Syntax Errors + + | +3 | # is parsed as a tuple, but this will always cause a runtime error, so we flag it +4 | # anyway +5 | with (foo, bar): ... + | ^ Syntax Error: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) +6 | with ( +7 | open('foo.txt')) as foo: ... + | + + + | + 6 | with ( + 7 | open('foo.txt')) as foo: ... + 8 | with ( + | ^ Syntax Error: Cannot use parentheses within a `with` statement on Python 3.8 (syntax was added in Python 3.9) + 9 | foo, +10 | bar, + | + + + | +11 | baz, +12 | ): ... +13 | with (foo,): ... + | ^ 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_py39.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_context_manager_py39.py.snap new file mode 100644 index 0000000000..fa05d59e24 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_context_manager_py39.py.snap @@ -0,0 +1,173 @@ +--- +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..126, + 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, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 74..99, + is_async: false, + items: [ + WithItem { + range: 80..83, + context_expr: Name( + ExprName { + range: 80..83, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: None, + }, + WithItem { + range: 85..93, + context_expr: Name( + ExprName { + range: 85..88, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 92..93, + id: Name("y"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + range: 96..99, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 96..99, + }, + ), + }, + ), + ], + }, + ), + With( + StmtWith { + range: 100..125, + is_async: false, + items: [ + WithItem { + range: 106..114, + context_expr: Name( + ExprName { + range: 106..109, + id: Name("foo"), + ctx: Load, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 113..114, + id: Name("x"), + ctx: Store, + }, + ), + ), + }, + WithItem { + range: 116..119, + context_expr: Name( + ExprName { + range: 116..119, + id: Name("bar"), + ctx: Load, + }, + ), + optional_vars: None, + }, + ], + body: [ + Expr( + StmtExpr { + range: 122..125, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 122..125, + }, + ), + }, + ), + ], + }, + ), + ], + }, +) +``` diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@tuple_context_manager_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@tuple_context_manager_py38.py.snap new file mode 100644 index 0000000000..7018039415 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@tuple_context_manager_py38.py.snap @@ -0,0 +1,77 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/tuple_context_manager_py38.py +--- +## AST + +``` +Module( + ModModule { + range: 0..85, + body: [ + With( + StmtWith { + range: 43..84, + is_async: false, + items: [ + WithItem { + range: 48..79, + context_expr: Tuple( + ExprTuple { + range: 48..72, + elts: [ + Name( + ExprName { + range: 52..55, + id: Name("foo"), + ctx: Load, + }, + ), + Name( + ExprName { + range: 59..62, + id: Name("bar"), + ctx: Load, + }, + ), + Name( + ExprName { + range: 66..69, + id: Name("baz"), + ctx: Load, + }, + ), + ], + ctx: Load, + parenthesized: true, + }, + ), + optional_vars: Some( + Name( + ExprName { + range: 76..79, + id: Name("tup"), + ctx: Store, + }, + ), + ), + }, + ], + body: [ + Expr( + StmtExpr { + range: 81..84, + value: EllipsisLiteral( + ExprEllipsisLiteral { + range: 81..84, + }, + ), + }, + ), + ], + }, + ), + ], + }, +) +```