From 79406714e51bba855ae5c4ff6401e4d406ddbd81 Mon Sep 17 00:00:00 2001 From: Micha Reiser Date: Thu, 21 Dec 2023 11:51:30 +0800 Subject: [PATCH] Parenthesize multi-context managers --- .../fixtures/ruff/statement/with.options.json | 9 + .../ruff/statement/with_39.options.json | 6 + .../test/fixtures/ruff/statement/with_39.py | 88 ++++ .../src/expression/mod.rs | 21 +- .../src/other/with_item.rs | 55 ++- crates/ruff_python_formatter/src/preview.rs | 9 + .../src/statement/stmt_with.rs | 157 +++++-- ...cases__preview_context_managers_39.py.snap | 342 -------------- ...ew_context_managers_autodetect_310.py.snap | 79 ---- ...ew_context_managers_autodetect_311.py.snap | 82 ---- ...iew_context_managers_autodetect_39.py.snap | 81 ---- .../snapshots/format@statement__with.py.snap | 426 +++++++++++++++++- .../format@statement__with_39.py.snap | 222 +++++++++ 13 files changed, 928 insertions(+), 649 deletions(-) create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.options.json create mode 100644 crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.py delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_39.py.snap delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_310.py.snap delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_311.py.snap delete mode 100644 crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_39.py.snap create mode 100644 crates/ruff_python_formatter/tests/snapshots/format@statement__with_39.py.snap diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with.options.json new file mode 100644 index 00000000000000..2eeb9813abd27c --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with.options.json @@ -0,0 +1,9 @@ +[ + { + "target_version": "py38" + }, + { + "target_version": "py39", + "preview": "enabled" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.options.json new file mode 100644 index 00000000000000..3621449825eea0 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.options.json @@ -0,0 +1,6 @@ +[ + { + "target_version": "py39", + "preview": "enabled" + } +] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.py new file mode 100644 index 00000000000000..e1b2da74e4ea01 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.py @@ -0,0 +1,88 @@ +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + + +# Black avoids parenthesizing the with because it can make all with items fit by just breaking +# around parentheses. We don't implement this optimisation because it makes it difficult to see where +# the different context managers start and end. +with cmd, xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +) as cmd, another, and_more as x: + pass + +# Avoid parenthesizing single item context managers when splitting after the parentheses (can_omit_optional_parentheses) +# is sufficient +with xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +).another_method(): pass + +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + + +# Black avoids parentheses here because it can make the entire with +# header fit without requiring parentheses to do so. +# We don't implement this optimisation because it very difficult to see where +# the different context managers start or end. +with cmd, xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +) as cmd, another, and_more as x: + pass + +# Avoid parenthesizing single item context managers when splitting after the parentheses +# is sufficient +with xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +).another_method(): pass + +# Parenthesize the with items if it makes them fit. Breaking the binary expression isn't +# necessary because the entire items fit just into the 88 character limit. +with aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as c: + pass + + +# Black parenthesizes this binary expression but also preserves the parentheses of the first with-item. +# It does so because it prefers splitting already parenthesized context managers, even if it leads to more parentheses +# like in this case. +with ( + ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + ) as b, + c as d, +): + pass + +if True: + with anyio.CancelScope(shield=True) if get_running_loop() else contextlib.nullcontext(): + pass + +with (aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as c): + pass + + diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs index 7d858694c04368..f010be3ae985df 100644 --- a/crates/ruff_python_formatter/src/expression/mod.rs +++ b/crates/ruff_python_formatter/src/expression/mod.rs @@ -529,7 +529,7 @@ impl<'ast> IntoFormat> for Expr { /// /// This mimics Black's [`_maybe_split_omitting_optional_parens`](https://github.com/psf/black/blob/d1248ca9beaf0ba526d265f4108836d89cf551b7/src/black/linegen.py#L746-L820) #[allow(clippy::if_same_then_else)] -fn can_omit_optional_parentheses(expr: &Expr, context: &PyFormatContext) -> bool { +pub(crate) fn can_omit_optional_parentheses(expr: &Expr, context: &PyFormatContext) -> bool { let mut visitor = CanOmitOptionalParenthesesVisitor::new(context); visitor.visit_subexpression(expr); @@ -538,8 +538,8 @@ fn can_omit_optional_parentheses(expr: &Expr, context: &PyFormatContext) -> bool false } else if visitor.max_precedence_count > 1 { false - } else if visitor.max_precedence == OperatorPrecedence::None && expr.is_lambda_expr() { - // Micha: This seems to exclusively apply for lambda expressions where the body ends in a subscript. + } else if visitor.max_precedence == OperatorPrecedence::None { + // Micha: This seems to apply for lambda expressions where the body ends in a subscript. // Subscripts are excluded by default because breaking them looks odd, but it seems to be fine for lambda expression. // // ```python @@ -566,10 +566,19 @@ fn can_omit_optional_parentheses(expr: &Expr, context: &PyFormatContext) -> bool // ] // ) // ``` + // + // Another case are method chains: + // ```python + // xxxxxxxx.some_kind_of_method( + // some_argument=[ + // "first", + // "second", + // "third", + // ] + // ).another_method(a) + // ``` true - } else if visitor.max_precedence == OperatorPrecedence::Attribute - && (expr.is_lambda_expr() || expr.is_named_expr_expr()) - { + } else if visitor.max_precedence == OperatorPrecedence::Attribute { // A single method call inside a named expression (`:=`) or as the body of a lambda function: // ```python // kwargs["open_with"] = lambda path, _: fsspec.open( diff --git a/crates/ruff_python_formatter/src/other/with_item.rs b/crates/ruff_python_formatter/src/other/with_item.rs index 59a79187771348..fc815a97d62119 100644 --- a/crates/ruff_python_formatter/src/other/with_item.rs +++ b/crates/ruff_python_formatter/src/other/with_item.rs @@ -1,5 +1,4 @@ use ruff_formatter::write; - use ruff_python_ast::WithItem; use crate::comments::SourceComment; @@ -8,6 +7,7 @@ use crate::expression::parentheses::{ is_expression_parenthesized, parenthesized, Parentheses, Parenthesize, }; use crate::prelude::*; +use crate::preview::is_wrap_multiple_context_managers_in_parens_enabled; #[derive(Default)] pub struct FormatWithItem; @@ -23,26 +23,49 @@ impl FormatNodeRule for FormatWithItem { let comments = f.context().comments().clone(); let trailing_as_comments = comments.dangling(item); - // Prefer keeping parentheses for already parenthesized expressions over - // parenthesizing other nodes. - let parenthesize = if is_expression_parenthesized( + let is_parenthesized = is_expression_parenthesized( context_expr.into(), f.context().comments().ranges(), f.context().source(), - ) { - Parenthesize::IfBreaks + ); + + // Remove the parentheses of the `with_items` if the with statement adds parentheses + if f.context().node_level().is_parenthesized() + && is_wrap_multiple_context_managers_in_parens_enabled(f.context()) + { + if is_parenthesized { + // ...except if the with item is parenthesized, then use this with item as a preferred breaking point + // or when it has comments, then parenthesize it to prevent comments from moving. + maybe_parenthesize_expression( + context_expr, + item, + Parenthesize::IfBreaksOrIfRequired, + ) + .fmt(f)?; + } else { + context_expr + .format() + .with_options(Parentheses::Never) + .fmt(f)?; + } } else { - Parenthesize::IfRequired - }; + // Prefer keeping parentheses for already parenthesized expressions over + // parenthesizing other nodes. + let parenthesize = if is_parenthesized { + Parenthesize::IfBreaks + } else { + Parenthesize::IfRequired + }; - write!( - f, - [maybe_parenthesize_expression( - context_expr, - item, - parenthesize - )] - )?; + write!( + f, + [maybe_parenthesize_expression( + context_expr, + item, + parenthesize + )] + )?; + } if let Some(optional_vars) = optional_vars { write!(f, [space(), token("as"), space()])?; diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index 4de87cf05c9118..596f2b783b6916 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -33,3 +33,12 @@ pub(crate) const fn is_no_blank_line_before_class_docstring_enabled( ) -> bool { context.is_preview() } + +/// Returns `true` if the [`wrap_multiple_context_managers_in_parens`](https://github.com/astral-sh/ruff/issues/8889) preview style is enabled. +/// +/// Unlike Black, we re-use the same preview style feature flag for [`improved_async_statements_handling`](https://github.com/astral-sh/ruff/issues/8890) +pub(crate) const fn is_wrap_multiple_context_managers_in_parens_enabled( + context: &PyFormatContext, +) -> bool { + context.is_preview() +} diff --git a/crates/ruff_python_formatter/src/statement/stmt_with.rs b/crates/ruff_python_formatter/src/statement/stmt_with.rs index 06dc9a5f88f6b7..3e8970f4aa154e 100644 --- a/crates/ruff_python_formatter/src/statement/stmt_with.rs +++ b/crates/ruff_python_formatter/src/statement/stmt_with.rs @@ -6,17 +6,21 @@ use ruff_text_size::{Ranged, TextRange}; use crate::builders::parenthesize_if_expands; use crate::comments::SourceComment; -use crate::expression::parentheses::parenthesized; +use crate::expression::can_omit_optional_parentheses; +use crate::expression::parentheses::{ + is_expression_parenthesized, optional_parentheses, parenthesized, +}; use crate::other::commas; use crate::prelude::*; +use crate::preview::is_wrap_multiple_context_managers_in_parens_enabled; use crate::statement::clause::{clause_body, clause_header, ClauseHeader}; -use crate::PyFormatOptions; +use crate::{PyFormatOptions, PythonVersion}; #[derive(Default)] pub struct FormatStmtWith; impl FormatNodeRule for FormatStmtWith { - fn fmt_fields(&self, item: &StmtWith, f: &mut PyFormatter) -> FormatResult<()> { + fn fmt_fields(&self, with_stmt: &StmtWith, f: &mut PyFormatter) -> FormatResult<()> { // The `with` statement can have one dangling comment on the open parenthesis, like: // ```python // with ( # comment @@ -31,9 +35,10 @@ impl FormatNodeRule for FormatStmtWith { // ... // ``` let comments = f.context().comments().clone(); - let dangling_comments = comments.dangling(item.as_any_node_ref()); + let dangling_comments = comments.dangling(with_stmt.as_any_node_ref()); let partition_point = dangling_comments.partition_point(|comment| { - item.items + with_stmt + .items .first() .is_some_and(|with_item| with_item.start() > comment.start()) }); @@ -43,35 +48,26 @@ impl FormatNodeRule for FormatStmtWith { f, [ clause_header( - ClauseHeader::With(item), + ClauseHeader::With(with_stmt), colon_comments, &format_with(|f| { write!( f, [ - item.is_async + with_stmt + .is_async .then_some(format_args![token("async"), space()]), token("with"), space() ] )?; - if !parenthesized_comments.is_empty() { - let joined = format_with(|f: &mut PyFormatter| { - f.join_comma_separated(item.body.first().unwrap().start()) - .nodes(&item.items) - .finish() - }); - - parenthesized("(", &joined, ")") - .with_dangling_comments(parenthesized_comments) - .fmt(f)?; - } else if should_parenthesize(item, f.options(), f.context())? { - parenthesize_if_expands(&format_with(|f| { + if parenthesized_comments.is_empty() { + let format_items = format_with(|f| { let mut joiner = - f.join_comma_separated(item.body.first().unwrap().start()); + f.join_comma_separated(with_stmt.body.first().unwrap().start()); - for item in &item.items { + for item in &with_stmt.items { joiner.entry_with_line_separator( item, &item.format(), @@ -79,26 +75,48 @@ impl FormatNodeRule for FormatStmtWith { ); } joiner.finish() - })) - .fmt(f)?; - } else if let [item] = item.items.as_slice() { - // This is similar to `maybe_parenthesize_expression`, but we're not - // dealing with an expression here, it's a `WithItem`. - if comments.has_leading(item) || comments.has_trailing(item) { - parenthesized("(", &item.format(), ")").fmt(f)?; - } else { - item.format().fmt(f)?; + }); + + match should_parenthesize(with_stmt, f.options(), f.context())? { + ParenthesizeWith::Optional => { + optional_parentheses(&format_items).fmt(f)?; + } + ParenthesizeWith::IfExpands => { + parenthesize_if_expands(&format_items).fmt(f)?; + } + ParenthesizeWith::UnlessCommented => { + if let [item] = with_stmt.items.as_slice() { + // This is similar to `maybe_parenthesize_expression`, but we're not + // dealing with an expression here, it's a `WithItem`. + if comments.has_leading(item) || comments.has_trailing(item) + { + parenthesized("(", &item.format(), ")").fmt(f)?; + } else { + item.format().fmt(f)?; + } + } else { + f.join_with(format_args![token(","), space()]) + .entries(with_stmt.items.iter().formatted()) + .finish()?; + } + } } } else { - f.join_with(format_args![token(","), space()]) - .entries(item.items.iter().formatted()) - .finish()?; + let joined = format_with(|f: &mut PyFormatter| { + f.join_comma_separated(with_stmt.body.first().unwrap().start()) + .nodes(&with_stmt.items) + .finish() + }); + + parenthesized("(", &joined, ")") + .with_dangling_comments(parenthesized_comments) + .fmt(f)?; } Ok(()) }) ), - clause_body(&item.body, colon_comments) + clause_body(&with_stmt.body, colon_comments) ] ) } @@ -113,24 +131,79 @@ impl FormatNodeRule for FormatStmtWith { } } -/// Returns `true` if the `with` items should be parenthesized, if at least one item expands. +/// Determines whether the `with` items should be parenthesized (over parenthesizing each item), +/// and if so, which parenthesizing layout to use. /// -/// Black parenthesizes `with` items if there's more than one item and they're already -/// parenthesized, _or_ there's a single item with a trailing comma. +/// Parenthesize `with` items if +/// * The last item has a trailing comma (implying that the with items were parenthesized in the source) +/// * There's more than one item and they're already parenthesized +/// * There's more than one item, the [`wrap_multiple_context_managers_in_parens`](is_wrap_multiple_context_managers_in_parens) preview style is enabled, +/// and the target python version is >= 3.9 +/// * There's a single non-parenthesized item. The function returns [`ParenthesizeWith::Optional`] +/// if the parentheses can be omitted if breaking around parenthesized sub-expressions is sufficient +/// to make the expression fit. It returns [`ParenthesizeWith::IfExpands`] otherwise. +/// * The only item is parenthesized and has comments. fn should_parenthesize( with: &StmtWith, options: &PyFormatOptions, context: &PyFormatContext, -) -> FormatResult { +) -> FormatResult { if has_magic_trailing_comma(with, options, context) { - return Ok(true); + return Ok(ParenthesizeWith::IfExpands); } - if are_with_items_parenthesized(with, context)? { - return Ok(true); + let can_parenthesize = (is_wrap_multiple_context_managers_in_parens_enabled(context) + && options.target_version() >= PythonVersion::Py39) + || are_with_items_parenthesized(with, context)?; + + if !can_parenthesize { + return Ok(ParenthesizeWith::UnlessCommented); } - Ok(false) + if let [single] = with.items.as_slice() { + return Ok( + // If the with item itself has comments (not the context expression), then keep the parentheses + if context.comments().has_leading(single) || context.comments().has_trailing(single) { + ParenthesizeWith::IfExpands + } + // If it is the only expression and it has comments, then the with statement + // as well as the with item add parentheses + else if is_expression_parenthesized( + (&single.context_expr).into(), + context.comments().ranges(), + context.source(), + ) { + // Preserve the parentheses around the context expression instead of parenthesizing the entire + // with items. + ParenthesizeWith::UnlessCommented + } else if is_wrap_multiple_context_managers_in_parens_enabled(context) + && can_omit_optional_parentheses(&single.context_expr, context) + { + ParenthesizeWith::Optional + } else { + ParenthesizeWith::IfExpands + }, + ); + } + + // Always parenthesize multiple items + Ok(ParenthesizeWith::IfExpands) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ParenthesizeWith { + /// Don't wrap the with items in parentheses except if it is a single item + /// and it has leading or trailing comment. + /// + /// This is required because `are_with_items_parenthesized` cannot determine if + /// `with (expr)` is a parenthesized expression or a parenthesized with item. + UnlessCommented, + + /// Wrap the with items in optional parentheses + Optional, + + /// Wrap the with items in parentheses if they expand + IfExpands, } fn has_magic_trailing_comma( diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_39.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_39.py.snap deleted file mode 100644 index ed87cfaba128dd..00000000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_39.py.snap +++ /dev/null @@ -1,342 +0,0 @@ ---- -source: crates/ruff_python_formatter/tests/fixtures.rs -input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_context_managers_39.py ---- -## Input - -```python -with \ - make_context_manager1() as cm1, \ - make_context_manager2() as cm2, \ - make_context_manager3() as cm3, \ - make_context_manager4() as cm4 \ -: - pass - - -# Leading comment -with \ - make_context_manager1() as cm1, \ - make_context_manager2(), \ - make_context_manager3() as cm3, \ - make_context_manager4() \ -: - pass - - -with \ - new_new_new1() as cm1, \ - new_new_new2() \ -: - pass - - -with ( - new_new_new1() as cm1, - new_new_new2() -): - pass - - -# Leading comment. -with ( - # First comment. - new_new_new1() as cm1, - # Second comment. - new_new_new2() - # Last comment. -): - pass - - -with \ - this_is_a_very_long_call(looong_arg1=looong_value1, looong_arg2=looong_value2) as cm1, \ - this_is_a_very_long_call(looong_arg1=looong_value1, looong_arg2=looong_value2, looong_arg3=looong_value3, looong_arg4=looong_value4) as cm2 \ -: - pass - - -with mock.patch.object( - self.my_runner, "first_method", autospec=True -) as mock_run_adb, mock.patch.object( - self.my_runner, "second_method", autospec=True, return_value="foo" -): - pass - - -with xxxxxxxx.some_kind_of_method( - some_argument=[ - "first", - "second", - "third", - ] -).another_method() as cmd: - pass - - -async def func(): - async with \ - make_context_manager1() as cm1, \ - make_context_manager2() as cm2, \ - make_context_manager3() as cm3, \ - make_context_manager4() as cm4 \ - : - pass - - async with some_function( - argument1, argument2, argument3="some_value" - ) as some_cm, some_other_function( - argument1, argument2, argument3="some_value" - ): - pass -``` - -## Black Differences - -```diff ---- Black -+++ Ruff -@@ -1,19 +1,9 @@ --with ( -- make_context_manager1() as cm1, -- make_context_manager2() as cm2, -- make_context_manager3() as cm3, -- make_context_manager4() as cm4, --): -+with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass - - - # Leading comment --with ( -- make_context_manager1() as cm1, -- make_context_manager2(), -- make_context_manager3() as cm3, -- make_context_manager4(), --): -+with make_context_manager1() as cm1, make_context_manager2(), make_context_manager3() as cm3, make_context_manager4(): - pass - - -@@ -36,25 +26,21 @@ - pass - - --with ( -- this_is_a_very_long_call( -- looong_arg1=looong_value1, looong_arg2=looong_value2 -- ) as cm1, -- this_is_a_very_long_call( -- looong_arg1=looong_value1, -- looong_arg2=looong_value2, -- looong_arg3=looong_value3, -- looong_arg4=looong_value4, -- ) as cm2, --): -+with this_is_a_very_long_call( -+ looong_arg1=looong_value1, looong_arg2=looong_value2 -+) as cm1, this_is_a_very_long_call( -+ looong_arg1=looong_value1, -+ looong_arg2=looong_value2, -+ looong_arg3=looong_value3, -+ looong_arg4=looong_value4, -+) as cm2: - pass - - --with ( -- mock.patch.object(self.my_runner, "first_method", autospec=True) as mock_run_adb, -- mock.patch.object( -- self.my_runner, "second_method", autospec=True, return_value="foo" -- ), -+with mock.patch.object( -+ self.my_runner, "first_method", autospec=True -+) as mock_run_adb, mock.patch.object( -+ self.my_runner, "second_method", autospec=True, return_value="foo" - ): - pass - -@@ -70,16 +56,10 @@ - - - async def func(): -- async with ( -- make_context_manager1() as cm1, -- make_context_manager2() as cm2, -- make_context_manager3() as cm3, -- make_context_manager4() as cm4, -- ): -+ async with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass - -- async with ( -- some_function(argument1, argument2, argument3="some_value") as some_cm, -- some_other_function(argument1, argument2, argument3="some_value"), -- ): -+ async with some_function( -+ argument1, argument2, argument3="some_value" -+ ) as some_cm, some_other_function(argument1, argument2, argument3="some_value"): - pass -``` - -## Ruff Output - -```python -with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass - - -# Leading comment -with make_context_manager1() as cm1, make_context_manager2(), make_context_manager3() as cm3, make_context_manager4(): - pass - - -with new_new_new1() as cm1, new_new_new2(): - pass - - -with new_new_new1() as cm1, new_new_new2(): - pass - - -# Leading comment. -with ( - # First comment. - new_new_new1() as cm1, - # Second comment. - new_new_new2(), - # Last comment. -): - pass - - -with this_is_a_very_long_call( - looong_arg1=looong_value1, looong_arg2=looong_value2 -) as cm1, this_is_a_very_long_call( - looong_arg1=looong_value1, - looong_arg2=looong_value2, - looong_arg3=looong_value3, - looong_arg4=looong_value4, -) as cm2: - pass - - -with mock.patch.object( - self.my_runner, "first_method", autospec=True -) as mock_run_adb, mock.patch.object( - self.my_runner, "second_method", autospec=True, return_value="foo" -): - pass - - -with xxxxxxxx.some_kind_of_method( - some_argument=[ - "first", - "second", - "third", - ] -).another_method() as cmd: - pass - - -async def func(): - async with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass - - async with some_function( - argument1, argument2, argument3="some_value" - ) as some_cm, some_other_function(argument1, argument2, argument3="some_value"): - pass -``` - -## Black Output - -```python -with ( - make_context_manager1() as cm1, - make_context_manager2() as cm2, - make_context_manager3() as cm3, - make_context_manager4() as cm4, -): - pass - - -# Leading comment -with ( - make_context_manager1() as cm1, - make_context_manager2(), - make_context_manager3() as cm3, - make_context_manager4(), -): - pass - - -with new_new_new1() as cm1, new_new_new2(): - pass - - -with new_new_new1() as cm1, new_new_new2(): - pass - - -# Leading comment. -with ( - # First comment. - new_new_new1() as cm1, - # Second comment. - new_new_new2(), - # Last comment. -): - pass - - -with ( - this_is_a_very_long_call( - looong_arg1=looong_value1, looong_arg2=looong_value2 - ) as cm1, - this_is_a_very_long_call( - looong_arg1=looong_value1, - looong_arg2=looong_value2, - looong_arg3=looong_value3, - looong_arg4=looong_value4, - ) as cm2, -): - pass - - -with ( - mock.patch.object(self.my_runner, "first_method", autospec=True) as mock_run_adb, - mock.patch.object( - self.my_runner, "second_method", autospec=True, return_value="foo" - ), -): - pass - - -with xxxxxxxx.some_kind_of_method( - some_argument=[ - "first", - "second", - "third", - ] -).another_method() as cmd: - pass - - -async def func(): - async with ( - make_context_manager1() as cm1, - make_context_manager2() as cm2, - make_context_manager3() as cm3, - make_context_manager4() as cm4, - ): - pass - - async with ( - some_function(argument1, argument2, argument3="some_value") as some_cm, - some_other_function(argument1, argument2, argument3="some_value"), - ): - pass -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_310.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_310.py.snap deleted file mode 100644 index abee1610d434c5..00000000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_310.py.snap +++ /dev/null @@ -1,79 +0,0 @@ ---- -source: crates/ruff_python_formatter/tests/fixtures.rs -input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_context_managers_autodetect_310.py ---- -## Input - -```python -# This file uses pattern matching introduced in Python 3.10. - - -match http_code: - case 404: - print("Not found") - - -with \ - make_context_manager1() as cm1, \ - make_context_manager2() as cm2, \ - make_context_manager3() as cm3, \ - make_context_manager4() as cm4 \ -: - pass -``` - -## Black Differences - -```diff ---- Black -+++ Ruff -@@ -6,10 +6,5 @@ - print("Not found") - - --with ( -- make_context_manager1() as cm1, -- make_context_manager2() as cm2, -- make_context_manager3() as cm3, -- make_context_manager4() as cm4, --): -+with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass -``` - -## Ruff Output - -```python -# This file uses pattern matching introduced in Python 3.10. - - -match http_code: - case 404: - print("Not found") - - -with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass -``` - -## Black Output - -```python -# This file uses pattern matching introduced in Python 3.10. - - -match http_code: - case 404: - print("Not found") - - -with ( - make_context_manager1() as cm1, - make_context_manager2() as cm2, - make_context_manager3() as cm3, - make_context_manager4() as cm4, -): - pass -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_311.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_311.py.snap deleted file mode 100644 index 71002c8d5d700f..00000000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_311.py.snap +++ /dev/null @@ -1,82 +0,0 @@ ---- -source: crates/ruff_python_formatter/tests/fixtures.rs -input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_context_managers_autodetect_311.py ---- -## Input - -```python -# This file uses except* clause in Python 3.11. - - -try: - some_call() -except* Error as e: - pass - - -with \ - make_context_manager1() as cm1, \ - make_context_manager2() as cm2, \ - make_context_manager3() as cm3, \ - make_context_manager4() as cm4 \ -: - pass -``` - -## Black Differences - -```diff ---- Black -+++ Ruff -@@ -7,10 +7,5 @@ - pass - - --with ( -- make_context_manager1() as cm1, -- make_context_manager2() as cm2, -- make_context_manager3() as cm3, -- make_context_manager4() as cm4, --): -+with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass -``` - -## Ruff Output - -```python -# This file uses except* clause in Python 3.11. - - -try: - some_call() -except* Error as e: - pass - - -with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass -``` - -## Black Output - -```python -# This file uses except* clause in Python 3.11. - - -try: - some_call() -except* Error as e: - pass - - -with ( - make_context_manager1() as cm1, - make_context_manager2() as cm2, - make_context_manager3() as cm3, - make_context_manager4() as cm4, -): - pass -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_39.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_39.py.snap deleted file mode 100644 index e1aeaa9faf8b97..00000000000000 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_context_managers_autodetect_39.py.snap +++ /dev/null @@ -1,81 +0,0 @@ ---- -source: crates/ruff_python_formatter/tests/fixtures.rs -input_file: crates/ruff_python_formatter/resources/test/fixtures/black/cases/preview_context_managers_autodetect_39.py ---- -## Input - -```python -# This file uses parenthesized context managers introduced in Python 3.9. - - -with \ - make_context_manager1() as cm1, \ - make_context_manager2() as cm2, \ - make_context_manager3() as cm3, \ - make_context_manager4() as cm4 \ -: - pass - - -with ( - new_new_new1() as cm1, - new_new_new2() -): - pass -``` - -## Black Differences - -```diff ---- Black -+++ Ruff -@@ -1,12 +1,7 @@ - # This file uses parenthesized context managers introduced in Python 3.9. - - --with ( -- make_context_manager1() as cm1, -- make_context_manager2() as cm2, -- make_context_manager3() as cm3, -- make_context_manager4() as cm4, --): -+with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass - - -``` - -## Ruff Output - -```python -# This file uses parenthesized context managers introduced in Python 3.9. - - -with make_context_manager1() as cm1, make_context_manager2() as cm2, make_context_manager3() as cm3, make_context_manager4() as cm4: - pass - - -with new_new_new1() as cm1, new_new_new2(): - pass -``` - -## Black Output - -```python -# This file uses parenthesized context managers introduced in Python 3.9. - - -with ( - make_context_manager1() as cm1, - make_context_manager2() as cm2, - make_context_manager3() as cm3, - make_context_manager4() as cm4, -): - pass - - -with new_new_new1() as cm1, new_new_new2(): - pass -``` - - diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap index 62018dfe4e1d4f..b263f5abdaa24a 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__with.py.snap @@ -315,7 +315,21 @@ with Child(aaaaaaaaa, bbbbbbbbbbbbbbb, cccccc), Document(aaaaa, bbbbbbbbbb, dddd pass ``` -## Output +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Disabled +target_version = Py38 +``` + ```python with aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: pass @@ -653,4 +667,414 @@ with Child(aaaaaaaaa, bbbbbbbbbbbbbbb, cccccc), Document( ``` +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -295,8 +295,9 @@ + pass + + with ( +- aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +- + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as b, ++ ( ++ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb ++ ) as b, + c as d, + ): + pass +``` + + +### Output 2 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py39 +``` + +```python +with ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, +): + pass + # trailing + +with a, a: # after colon + pass + # trailing + +with ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, +): + pass + # trailing + + +with ( + a, # a # comma + b, # c +): # colon + pass + + +with ( + a as ( # a # as + # own line + b + ), # b # comma + c, # c +): # colon + pass # body + # body trailing own + +with ( + a as ( # a # as + # own line + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + ) # b +): + pass + + +with ( + a, +): # magic trailing comma + pass + + +with a: # should remove brackets + pass + +with ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as c +): + pass + + +# currently unparsable by black: https://github.com/psf/black/issues/3678 +with (name_2 for name_0 in name_4): + pass +with (a, *b): + pass + +with ( + # leading comment + a +) as b: + pass + +with ( + # leading comment + a as b +): + pass + +with ( + a as b + # trailing comment +): + pass + +with ( + a as ( + # leading comment + b + ) +): + pass + +with ( + a as ( + b + # trailing comment + ) +): + pass + +with ( + a as b # trailing same line comment + # trailing own line comment +): + pass + +with ( + a # trailing same line comment + # trailing own line comment +) as b: + pass + +with ( + ( + a + # trailing own line comment + ) as ( # trailing as same line comment + b + ) # trailing b same line comment +): + pass + +with ( + # comment + a +): + pass + +with ( + a # comment +): + pass + +with ( + a + # comment +): + pass + +with ( + # comment + a as b +): + pass + +with ( + a as b # comment +): + pass + +with ( + a as b + # comment +): + pass + +with ( + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccc", + dddddddddddddddddddddddddddddddd, + ] as example1, + aaaaaaaaaaaaaaaaaaaaaaaaaa + + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + + cccccccccccccccccccccccccccc + + ddddddddddddddddd as example2, + CtxManager2() as example2, + CtxManager2() as example2, + CtxManager2() as example2, +): + pass + +with ( + [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbb", + "cccccccccccccccccccccccccccccccccccccccccc", + dddddddddddddddddddddddddddddddd, + ] as example1, + aaaaaaaaaaaaaaaaaaaaaaaaaa + * bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + * cccccccccccccccccccccccccccc + + ddddddddddddddddd as example2, + CtxManager222222222222222() as example2, +): + pass + +# Comments on open parentheses +with ( # comment + CtxManager1() as example1, + CtxManager2() as example2, + CtxManager3() as example3, +): + pass + +with ( # outer comment + ( # inner comment + CtxManager1() + ) as example1, + CtxManager2() as example2, + CtxManager3() as example3, +): + pass + +with ( # outer comment + CtxManager() +) as example: + pass + +with ( + ( # outer comment + CtxManager() + ) as example, + ( # inner comment + CtxManager2() + ) as example2, +): + pass + +with ( # outer comment + CtxManager1(), + CtxManager2(), +) as example: + pass + +with ( # outer comment + ( # inner comment + CtxManager1() + ), + CtxManager2(), +) as example: + pass + +# Breaking of with items. +with ( + test as ( # bar # foo + # test + foo + ) +): + pass + +with ( + test as ( + # test + foo + ) +): + pass + +with ( + test as ( # bar # foo # baz + # test + foo + ) +): + pass + +with a as b, c as d: + pass + +with ( + a as b, + # foo + c as d, +): + pass + +with ( + a as ( # foo + b + ) +): + pass + +with f( + a, +) as b: + pass + +with (x := 1) as d: + pass + +with x[ + 1, + 2, +] as d: + pass + +with ( + f( + a, + ) as b, + c as d, +): + pass + +with ( + f( + a, + ) as b, + c as d, +): + pass + +with ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +) as b: + pass + +with ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as b +): + pass + +with ( + ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + ) as b, + c as d, +): + pass + +with ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as b, + c as d, +): + pass + +with ( + ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + ) as b, + c as d, +): + pass + +with foo() as bar, baz() as bop: + pass + +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + +if True: + with ( + anyio.CancelScope(shield=True) + and B + and [aaaaaaaa, bbbbbbbbbbbbb, cccccccccc, dddddddddddd, eeeeeeeeeeeee] + ): + pass + +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + + +with ( + Child(aaaaaaaaa, bbbbbbbbbbbbbbb, cccccc), + Document(aaaaa, bbbbbbbbbb, ddddddddddddd), +): + pass +``` + + diff --git a/crates/ruff_python_formatter/tests/snapshots/format@statement__with_39.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@statement__with_39.py.snap new file mode 100644 index 00000000000000..5e361ceed3d289 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@statement__with_39.py.snap @@ -0,0 +1,222 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/statement/with_39.py +--- +## Input +```python +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + + +# Black avoids parenthesizing the with because it can make all with items fit by just breaking +# around parentheses. We don't implement this optimisation because it makes it difficult to see where +# the different context managers start and end. +with cmd, xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +) as cmd, another, and_more as x: + pass + +# Avoid parenthesizing single item context managers when splitting after the parentheses (can_omit_optional_parentheses) +# is sufficient +with xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +).another_method(): pass + +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + + +# Black avoids parentheses here because it can make the entire with +# header fit without requiring parentheses to do so. +# We don't implement this optimisation because it very difficult to see where +# the different context managers start or end. +with cmd, xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +) as cmd, another, and_more as x: + pass + +# Avoid parenthesizing single item context managers when splitting after the parentheses +# is sufficient +with xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +).another_method(): pass + +# Parenthesize the with items if it makes them fit. Breaking the binary expression isn't +# necessary because the entire items fit just into the 88 character limit. +with aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as c: + pass + + +# Black parenthesizes this binary expression but also preserves the parentheses of the first with-item. +# It does so because it prefers splitting already parenthesized context managers, even if it leads to more parentheses +# like in this case. +with ( + ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + ) as b, + c as d, +): + pass + +if True: + with anyio.CancelScope(shield=True) if get_running_loop() else contextlib.nullcontext(): + pass + +with (aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as c): + pass + + +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 88 +indent-width = 4 +quote-style = Double +line-ending = LineFeed +magic-trailing-comma = Respect +docstring-code = Disabled +docstring-code-line-width = "dynamic" +preview = Enabled +target_version = Py39 +``` + +```python +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + + +# Black avoids parenthesizing the with because it can make all with items fit by just breaking +# around parentheses. We don't implement this optimisation because it makes it difficult to see where +# the different context managers start and end. +with ( + cmd, + xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] + ) as cmd, + another, + and_more as x, +): + pass + +# Avoid parenthesizing single item context managers when splitting after the parentheses (can_omit_optional_parentheses) +# is sufficient +with xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +).another_method(): + pass + +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + + +# Black avoids parentheses here because it can make the entire with +# header fit without requiring parentheses to do so. +# We don't implement this optimisation because it very difficult to see where +# the different context managers start or end. +with ( + cmd, + xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] + ) as cmd, + another, + and_more as x, +): + pass + +# Avoid parenthesizing single item context managers when splitting after the parentheses +# is sufficient +with xxxxxxxx.some_kind_of_method( + some_argument=[ + "first", + "second", + "third", + ] +).another_method(): + pass + +# Parenthesize the with items if it makes them fit. Breaking the binary expression isn't +# necessary because the entire items fit just into the 88 character limit. +with ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as c +): + pass + + +# Black parenthesizes this binary expression but also preserves the parentheses of the first with-item. +# It does so because it prefers splitting already parenthesized context managers, even if it leads to more parentheses +# like in this case. +with ( + ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + ) as b, + c as d, +): + pass + +if True: + with ( + anyio.CancelScope(shield=True) + if get_running_loop() + else contextlib.nullcontext() + ): + pass + +with ( + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb as c +): + pass +``` + + +