Keep lambda parameters on one line and parenthesize the body if it expands (#21385)

## Summary

This PR makes two changes to our formatting of `lambda` expressions:
1. We now parenthesize the body expression if it expands
2. We now try to keep the parameters on a single line

The latter of these fixes #8179:

Black formatting and this PR's formatting:

```py
def a():
    return b(
        c,
        d,
        e,
        f=lambda self, *args, **kwargs: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(
            *args, **kwargs
        ),
    )
```

Stable Ruff formatting

```py
def a():
    return b(
        c,
        d,
        e,
        f=lambda self,
        *args,
        **kwargs: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs),
    )
```

We don't parenthesize the body expression here because the call to
`aaaa...` has its own parentheses, but adding a binary operator shows
the new parenthesization:

```diff
@@ -3,7 +3,7 @@
         c,
         d,
         e,
-        f=lambda self, *args, **kwargs: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(
-            *args, **kwargs
-        ) + 1,
+        f=lambda self, *args, **kwargs: (
+            aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs) + 1
+        ),
     )
```

This is actually a new divergence from Black, which formats this input
like this:

```py
def a():
    return b(
        c,
        d,
        e,
        f=lambda self, *args, **kwargs: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(
            *args, **kwargs
        )
        + 1,
    )
```

But I think this is an improvement, unlike the case from #8179.

One other, smaller benefit is that because we now add parentheses to
lambda bodies, we also remove redundant parentheses:

```diff
 @pytest.mark.parametrize(
     "f",
     [
-        lambda x: (x.expanding(min_periods=5).cov(x, pairwise=True)),
-        lambda x: (x.expanding(min_periods=5).corr(x, pairwise=True)),
+        lambda x: x.expanding(min_periods=5).cov(x, pairwise=True),
+        lambda x: x.expanding(min_periods=5).corr(x, pairwise=True),
     ],
 )
 def test_moment_functions_zero_length_pairwise(f):
```

## Test Plan

New tests taken from #8465 and probably a few more I should grab from
the ecosystem results.

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
This commit is contained in:
Brent Westbrook 2025-12-12 12:02:25 -05:00 committed by GitHub
parent d5546508cf
commit 0ebdebddd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 2991 additions and 81 deletions

View File

@ -125,6 +125,13 @@ lambda a, /, c: a
*x: x *x: x
) )
(
lambda
# comment
*x,
**y: x
)
( (
lambda lambda
# comment 1 # comment 1
@ -196,6 +203,17 @@ lambda: ( # comment
x x
) )
(
lambda # 1
# 2
x, # 3
# 4
y
: # 5
# 6
x
)
( (
lambda lambda
x, x,
@ -204,6 +222,71 @@ lambda: ( # comment
z z
) )
# Leading
lambda x: (
lambda y: lambda z: x
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ y
+ z # Trailing
) # Trailing
# Leading
lambda x: lambda y: lambda z: [
x,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
y,
z
] # Trailing
# Trailing
lambda self, araa, kkkwargs=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs), e=1, f=2, g=2: d lambda self, araa, kkkwargs=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs), e=1, f=2, g=2: d
# Regression tests for https://github.com/astral-sh/ruff/issues/8179 # Regression tests for https://github.com/astral-sh/ruff/issues/8179
@ -228,6 +311,441 @@ def a():
g = 10 g = 10
) )
def a():
return b(
c,
d,
e,
f=lambda self, *args, **kwargs: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(
*args, **kwargs
) + 1,
)
# Additional ecosystem cases from https://github.com/astral-sh/ruff/pull/21385
class C:
def foo():
mock_service.return_value.bucket.side_effect = lambda name: (
source_bucket
if name == source_bucket_name
else storage.Bucket(mock_service, destination_bucket_name)
)
class C:
function_dict: Dict[Text, Callable[[CRFToken], Any]] = {
CRFEntityExtractorOptions.POS2: lambda crf_token: crf_token.pos_tag[:2]
if crf_token.pos_tag is not None
else None,
}
name = re.sub(r"[^\x21\x23-\x5b\x5d-\x7e]...............", lambda m: f"\\{m.group(0)}", p["name"])
def foo():
if True:
if True:
return (
lambda x: np.exp(cs(np.log(x.to(u.MeV).value))) * u.MeV * u.cm**2 / u.g
)
class C:
_is_recognized_dtype: Callable[[DtypeObj], bool] = lambda x: lib.is_np_dtype(
x, "M"
) or isinstance(x, DatetimeTZDtype)
class C:
def foo():
if True:
transaction_count = self._query_txs_for_range(
get_count_fn=lambda from_ts, to_ts, _chain_id=chain_id: db_evmtx.count_transactions_in_range(
chain_id=_chain_id,
from_ts=from_ts,
to_ts=to_ts,
),
)
transaction_count = self._query_txs_for_range(
get_count_fn=lambda from_ts, to_ts, _chain_id=chain_id: db_evmtx.count_transactions_in_range[_chain_id, from_ts, to_ts],
)
def ddb():
sql = (
lambda var, table, n=N: f"""
CREATE TABLE {table} AS
SELECT ROW_NUMBER() OVER () AS id, {var}
FROM (
SELECT {var}
FROM RANGE({n}) _ ({var})
ORDER BY RANDOM()
)
"""
)
long_assignment_target.with_attribute.and_a_slice[with_an_index] = ( # 1
# 2
lambda x, y, z: # 3
# 4
x + y + z # 5
# 6
)
long_assignment_target.with_attribute.and_a_slice[with_an_index] = (
lambda x, y, z: x + y + z
)
long_assignment_target.with_attribute.and_a_slice[with_an_index] = lambda x, y, z: x + y + z
very_long_variable_name_x, very_long_variable_name_y = lambda a: a + some_very_long_expression, lambda b: b * another_very_long_expression_here
very_long_variable_name_for_result += lambda x: very_long_function_call_that_should_definitely_be_parenthesized_now(x, more_args, additional_parameters)
if 1:
if 2:
if 3:
if self.location in EVM_EVMLIKE_LOCATIONS and database is not None:
exported_dict["notes"] = EVM_ADDRESS_REGEX.sub(
repl=lambda matched_address: self._maybe_add_label_with_address(
database=database,
matched_address=matched_address,
),
string=exported_dict["notes"],
)
class C:
def f():
return dict(
filter(
lambda intent_response: self.is_retrieval_intent_response(
intent_response
),
self.responses.items(),
)
)
@pytest.mark.parametrize(
"op",
[
# Not fluent
param(
lambda left, right: (
ibis.timestamp("2017-04-01")
),
),
# These four are fluent and fit on one line inside the parenthesized
# lambda body
param(
lambda left, right: (
ibis.timestamp("2017-04-01").cast(dt.date)
),
),
param(
lambda left, right: (
ibis.timestamp("2017-04-01").cast(dt.date).between(left, right)
),
),
param(lambda left, right: ibis.timestamp("2017-04-01").cast(dt.date)),
param(lambda left, right: ibis.timestamp("2017-04-01").cast(dt.date).between(left, right)),
# This is too long on one line in the lambda body and gets wrapped
# inside the body.
param(
lambda left, right: (
ibis.timestamp("2017-04-01").cast(dt.date).between(left, right).between(left, right)
),
),
],
)
def test_string_temporal_compare_between(con, op, left, right): ...
[
(
lambda eval_df, _: MetricValue(
scores=eval_df["prediction"].tolist(),
aggregate_results={"prediction_sum": sum(eval_df["prediction"])},
)
),
]
# reuses the list parentheses
lambda xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz: [xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz]
# adds parentheses around the body
lambda xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz: xxxxxxxxxxxxxxxxxxxx + yyyyyyyyyyyyyyyyyyyy + zzzzzzzzzzzzzzzzzzzz
# removes parentheses around the body
lambda xxxxxxxxxxxxxxxxxxxx: (xxxxxxxxxxxxxxxxxxxx + 1)
mapper = lambda x: dict_with_default[np.nan if isinstance(x, float) and np.isnan(x) else x]
lambda x, y, z: (
x + y + z
)
lambda x, y, z: (
x + y + z
# trailing body
)
lambda x, y, z: (
x + y + z # trailing eol body
)
lambda x, y, z: (
x + y + z
) # trailing lambda
lambda x, y, z: (
# leading body
x + y + z
)
lambda x, y, z: ( # leading eol body
x + y + z
)
(
lambda name:
source_bucket # trailing eol comment
if name == source_bucket_name
else storage.Bucket(mock_service, destination_bucket_name)
)
(
lambda name:
# dangling header comment
source_bucket
if name == source_bucket_name
else storage.Bucket(mock_service, destination_bucket_name)
)
x = (
lambda name:
# dangling header comment
source_bucket
if name == source_bucket_name
else storage.Bucket(mock_service, destination_bucket_name)
)
(
lambda name: # dangling header comment
(
source_bucket
if name == source_bucket_name
else storage.Bucket(mock_service, destination_bucket_name)
)
)
(
lambda from_ts, to_ts, _chain_id=chain_id: # dangling eol header comment
db_evmtx.count_transactions_in_range(
chain_id=_chain_id,
from_ts=from_ts,
to_ts=to_ts,
)
)
(
lambda from_ts, to_ts, _chain_id=chain_id:
# dangling header comment before call
db_evmtx.count_transactions_in_range(
chain_id=_chain_id,
from_ts=from_ts,
to_ts=to_ts,
)
)
(
lambda left, right:
# comment
ibis.timestamp("2017-04-01").cast(dt.date).between(left, right)
)
(
lambda left, right:
ibis.timestamp("2017-04-01") # comment
.cast(dt.date)
.between(left, right)
)
(
lambda xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy:
# comment
[xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz]
)
(
lambda x, y:
# comment
{
"key": x,
"another": y,
}
)
(
lambda x, y:
# comment
(
x,
y,
z
)
)
(
lambda x:
# comment
dict_with_default[np.nan if isinstance(x, float) and np.isnan(x) else x]
)
(
lambda from_ts, to_ts, _chain_id=chain_id:
db_evmtx.count_transactions_in_range[
# comment
_chain_id, from_ts, to_ts
]
)
(
lambda
# comment
*args, **kwargs:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs) + 1
)
(
lambda # comment
*args, **kwargs:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs) + 1
)
(
lambda # comment 1
# comment 2
*args, **kwargs: # comment 3
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs) + 1
)
(
lambda # comment 1
*args, **kwargs: # comment 3
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs) + 1
)
(
lambda *args, **kwargs:
# comment 1
( # comment 2
# comment 3
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*args, **kwargs) + 1 # comment 4
# comment 5
) # comment 6
)
(
lambda *brgs, **kwargs:
# comment 1
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa( # comment 2
# comment 3
*brgs, **kwargs) + 1 # comment 4
# comment 5
)
(
lambda *crgs, **kwargs: # comment 1
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*crgs, **kwargs) + 1
)
(
lambda *drgs, **kwargs: # comment 1
(
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*drgs, **kwargs) + 1
)
)
(
lambda * # comment 1
ergs, **
# comment 2
kwargs # comment 3
: # comment 4
(
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(*ergs, **kwargs) + 1
)
)
(
lambda # 1
# 2
left, # 3
# 4
right: # 5
# 6
ibis.timestamp("2017-04-01").cast(dt.date).between(left, right)
)
(
lambda x: # outer comment 1
(
lambda y: # inner comment 1
# inner comment 2
lambda z: (
# innermost comment
x + y + z
)
)
)
foo(
lambda from_ts, # comment prevents collapsing the parameters to one line
to_ts, _chain_id=chain_id: db_evmtx.count_transactions_in_range(
chain_id=_chain_id,
from_ts=from_ts,
to_ts=to_ts,
)
)
foo(
lambda from_ts, # but still wrap the body if it gets too long
to_ts,
_chain_id=chain_id: db_evmtx.count_transactions_in_rangeeeeeeeeeeeeeeeeeeeeeeeeeeeee(
chain_id=_chain_id,
from_ts=from_ts,
to_ts=to_ts,
)
)
transform = lambda left, right: ibis.timestamp("2017-04-01").cast(dt.date).between(left, right).between(left, right) # trailing comment
(
lambda: # comment
1
)
(
lambda # comment
:
1
)
(
lambda:
# comment
1
)
(
lambda: # comment 1
# comment 2
1
)
(
lambda # comment 1
# comment 2
: # comment 3
# comment 4
1
)
( (
lambda lambda
* # comment 2 * # comment 2
@ -271,3 +789,18 @@ def a():
x: x:
x x
) )
(
lambda: # dangling-end-of-line
# dangling-own-line
( # leading-body-end-of-line
x
)
)
(
lambda: # dangling-end-of-line
( # leading-body-end-of-line
x
)
)

View File

@ -1,4 +1,4 @@
use ruff_formatter::{Argument, Arguments, write}; use ruff_formatter::{Argument, Arguments, format_args, write};
use ruff_text_size::{Ranged, TextRange, TextSize}; use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::context::{NodeLevel, WithNodeLevel}; use crate::context::{NodeLevel, WithNodeLevel};
@ -33,20 +33,27 @@ impl<'ast> Format<PyFormatContext<'ast>> for ParenthesizeIfExpands<'_, 'ast> {
{ {
let mut f = WithNodeLevel::new(NodeLevel::ParenthesizedExpression, f); let mut f = WithNodeLevel::new(NodeLevel::ParenthesizedExpression, f);
write!(
f,
[group(&format_with(|f| {
if_group_breaks(&token("(")).fmt(f)?;
if self.indent { if self.indent {
soft_block_indent(&Arguments::from(&self.inner)).fmt(f)?; let parens_id = f.group_id("indented_parenthesize_if_expands");
group(&format_args![
if_group_breaks(&token("(")),
indent_if_group_breaks(
&format_args![soft_line_break(), &Arguments::from(&self.inner)],
parens_id
),
soft_line_break(),
if_group_breaks(&token(")"))
])
.with_id(Some(parens_id))
.fmt(&mut f)
} else { } else {
Arguments::from(&self.inner).fmt(f)?; group(&format_args![
if_group_breaks(&token("(")),
Arguments::from(&self.inner),
if_group_breaks(&token(")")),
])
.fmt(&mut f)
} }
if_group_breaks(&token(")")).fmt(f)
}))]
)
} }
} }
} }

View File

@ -1,15 +1,21 @@
use ruff_formatter::write; use ruff_formatter::{FormatRuleWithOptions, RemoveSoftLinesBuffer, format_args, write};
use ruff_python_ast::AnyNodeRef; use ruff_python_ast::{AnyNodeRef, Expr, ExprLambda};
use ruff_python_ast::ExprLambda;
use ruff_text_size::Ranged; use ruff_text_size::Ranged;
use crate::comments::dangling_comments; use crate::builders::parenthesize_if_expands;
use crate::expression::parentheses::{NeedsParentheses, OptionalParentheses}; use crate::comments::{SourceComment, dangling_comments, leading_comments, trailing_comments};
use crate::expression::parentheses::{
NeedsParentheses, OptionalParentheses, Parentheses, is_expression_parenthesized,
};
use crate::expression::{CallChainLayout, has_own_parentheses};
use crate::other::parameters::ParametersParentheses; use crate::other::parameters::ParametersParentheses;
use crate::prelude::*; use crate::prelude::*;
use crate::preview::is_parenthesize_lambda_bodies_enabled;
#[derive(Default)] #[derive(Default)]
pub struct FormatExprLambda; pub struct FormatExprLambda {
layout: ExprLambdaLayout,
}
impl FormatNodeRule<ExprLambda> for FormatExprLambda { impl FormatNodeRule<ExprLambda> for FormatExprLambda {
fn fmt_fields(&self, item: &ExprLambda, f: &mut PyFormatter) -> FormatResult<()> { fn fmt_fields(&self, item: &ExprLambda, f: &mut PyFormatter) -> FormatResult<()> {
@ -20,13 +26,19 @@ impl FormatNodeRule<ExprLambda> for FormatExprLambda {
body, body,
} = item; } = item;
let body = &**body;
let parameters = parameters.as_deref();
let comments = f.context().comments().clone(); let comments = f.context().comments().clone();
let dangling = comments.dangling(item); let dangling = comments.dangling(item);
let preview = is_parenthesize_lambda_bodies_enabled(f.context());
write!(f, [token("lambda")])?; write!(f, [token("lambda")])?;
if let Some(parameters) = parameters { // Format any dangling comments before the parameters, but save any dangling comments after
// In this context, a dangling comment can either be a comment between the `lambda` the // the parameters/after the header to be formatted with the body below.
let dangling_header_comments = if let Some(parameters) = parameters {
// In this context, a dangling comment can either be a comment between the `lambda` and the
// parameters, or a comment between the parameters and the body. // parameters, or a comment between the parameters and the body.
let (dangling_before_parameters, dangling_after_parameters) = dangling let (dangling_before_parameters, dangling_after_parameters) = dangling
.split_at(dangling.partition_point(|comment| comment.end() < parameters.start())); .split_at(dangling.partition_point(|comment| comment.end() < parameters.start()));
@ -86,7 +98,7 @@ impl FormatNodeRule<ExprLambda> for FormatExprLambda {
// *x: x // *x: x
// ) // )
// ``` // ```
if comments.has_leading(&**parameters) { if comments.has_leading(parameters) {
hard_line_break().fmt(f)?; hard_line_break().fmt(f)?;
} else { } else {
write!(f, [space()])?; write!(f, [space()])?;
@ -95,32 +107,90 @@ impl FormatNodeRule<ExprLambda> for FormatExprLambda {
write!(f, [dangling_comments(dangling_before_parameters)])?; write!(f, [dangling_comments(dangling_before_parameters)])?;
} }
// Try to keep the parameters on a single line, unless there are intervening comments.
if preview && !comments.contains_comments(parameters.into()) {
let mut buffer = RemoveSoftLinesBuffer::new(f);
write!(
buffer,
[parameters
.format()
.with_options(ParametersParentheses::Never)]
)?;
} else {
write!( write!(
f, f,
[parameters [parameters
.format() .format()
.with_options(ParametersParentheses::Never)] .with_options(ParametersParentheses::Never)]
)?; )?;
}
dangling_after_parameters
} else {
dangling
};
write!(f, [token(":")])?; write!(f, [token(":")])?;
if dangling_after_parameters.is_empty() { if dangling_header_comments.is_empty() {
write!(f, [space()])?; write!(f, [space()])?;
} else { } else if !preview {
write!(f, [dangling_comments(dangling_after_parameters)])?; write!(f, [dangling_comments(dangling_header_comments)])?;
}
} else {
write!(f, [token(":")])?;
// In this context, a dangling comment is a comment between the `lambda` and the body.
if dangling.is_empty() {
write!(f, [space()])?;
} else {
write!(f, [dangling_comments(dangling)])?;
}
} }
write!(f, [body.format()]) if !preview {
return body.format().fmt(f);
}
let fmt_body = FormatBody {
body,
dangling_header_comments,
};
match self.layout {
ExprLambdaLayout::Assignment => fits_expanded(&fmt_body).fmt(f),
ExprLambdaLayout::Default => fmt_body.fmt(f),
}
}
}
#[derive(Debug, Default, Copy, Clone)]
pub enum ExprLambdaLayout {
#[default]
Default,
/// The [`ExprLambda`] is the direct child of an assignment expression, so it needs to use
/// `fits_expanded` to prefer parenthesizing its own body before the assignment tries to
/// parenthesize the whole lambda. For example, we want this formatting:
///
/// ```py
/// long_assignment_target = lambda x, y, z: (
/// x + y + z
/// )
/// ```
///
/// instead of either of these:
///
/// ```py
/// long_assignment_target = (
/// lambda x, y, z: (
/// x + y + z
/// )
/// )
///
/// long_assignment_target = (
/// lambda x, y, z: x + y + z
/// )
/// ```
Assignment,
}
impl FormatRuleWithOptions<ExprLambda, PyFormatContext<'_>> for FormatExprLambda {
type Options = ExprLambdaLayout;
fn with_options(mut self, options: Self::Options) -> Self {
self.layout = options;
self
} }
} }
@ -137,3 +207,266 @@ impl NeedsParentheses for ExprLambda {
} }
} }
} }
struct FormatBody<'a> {
body: &'a Expr,
/// Dangling comments attached to the lambda header that should be formatted with the body.
///
/// These can include both own-line and end-of-line comments. For lambdas with parameters, this
/// means comments after the parameters:
///
/// ```py
/// (
/// lambda x, y # 1
/// # 2
/// : # 3
/// # 4
/// x + y
/// )
/// ```
///
/// Or all dangling comments for lambdas without parameters:
///
/// ```py
/// (
/// lambda # 1
/// # 2
/// : # 3
/// # 4
/// 1
/// )
/// ```
///
/// In most cases these should formatted within the parenthesized body, as in:
///
/// ```py
/// (
/// lambda: ( # 1
/// # 2
/// # 3
/// # 4
/// 1
/// )
/// )
/// ```
///
/// or without `# 2`:
///
/// ```py
/// (
/// lambda: ( # 1 # 3
/// # 4
/// 1
/// )
/// )
/// ```
dangling_header_comments: &'a [SourceComment],
}
impl Format<PyFormatContext<'_>> for FormatBody<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let FormatBody {
dangling_header_comments,
body,
} = self;
let body = *body;
let comments = f.context().comments().clone();
let body_comments = comments.leading_dangling_trailing(body);
if !dangling_header_comments.is_empty() {
// Split the dangling header comments into trailing comments formatted with the lambda
// header (1) and leading comments formatted with the body (2, 3, 4).
//
// ```python
// (
// lambda # 1
// # 2
// : # 3
// # 4
// y
// )
// ```
//
// Note that these are split based on their line position rather than using
// `partition_point` based on a range, for example.
let (trailing_header_comments, leading_body_comments) = dangling_header_comments
.split_at(
dangling_header_comments
.iter()
.position(|comment| comment.line_position().is_own_line())
.unwrap_or(dangling_header_comments.len()),
);
// If the body is parenthesized and has its own leading comments, preserve the
// separation between the dangling lambda comments and the body comments. For
// example, preserve this comment positioning:
//
// ```python
// (
// lambda: # 1
// # 2
// ( # 3
// x
// )
// )
// ```
//
// 1 and 2 are dangling on the lambda and emitted first, followed by a hard line
// break and the parenthesized body with its leading comments.
//
// However, when removing 2, 1 and 3 can instead be formatted on the same line:
//
// ```python
// (
// lambda: ( # 1 # 3
// x
// )
// )
// ```
let comments = f.context().comments();
if is_expression_parenthesized(body.into(), comments.ranges(), f.context().source())
&& comments.has_leading(body)
{
trailing_comments(dangling_header_comments).fmt(f)?;
// Note that `leading_body_comments` have already been formatted as part of
// `dangling_header_comments` above, but their presence still determines the spacing
// here.
if leading_body_comments.is_empty() {
space().fmt(f)?;
} else {
hard_line_break().fmt(f)?;
}
body.format().with_options(Parentheses::Always).fmt(f)
} else {
write!(
f,
[
space(),
token("("),
trailing_comments(trailing_header_comments),
block_indent(&format_args!(
leading_comments(leading_body_comments),
body.format().with_options(Parentheses::Never)
)),
token(")")
]
)
}
}
// If the body has comments, we always want to preserve the parentheses. This also
// ensures that we correctly handle parenthesized comments, and don't need to worry
// about them in the implementation below.
else if body_comments.has_leading() || body_comments.has_trailing_own_line() {
body.format().with_options(Parentheses::Always).fmt(f)
}
// Calls and subscripts require special formatting because they have their own
// parentheses, but they can also have an arbitrary amount of text before the
// opening parenthesis. We want to avoid cases where we keep a long callable on the
// same line as the lambda parameters. For example, `db_evmtx...` in:
//
// ```py
// transaction_count = self._query_txs_for_range(
// get_count_fn=lambda from_ts, to_ts, _chain_id=chain_id: db_evmtx.count_transactions_in_range(
// chain_id=_chain_id,
// from_ts=from_ts,
// to_ts=to_ts,
// ),
// )
// ```
//
// should cause the whole lambda body to be parenthesized instead:
//
// ```py
// transaction_count = self._query_txs_for_range(
// get_count_fn=lambda from_ts, to_ts, _chain_id=chain_id: (
// db_evmtx.count_transactions_in_range(
// chain_id=_chain_id,
// from_ts=from_ts,
// to_ts=to_ts,
// )
// ),
// )
// ```
else if matches!(body, Expr::Call(_) | Expr::Subscript(_)) {
let unparenthesized = body.format().with_options(Parentheses::Never);
if CallChainLayout::from_expression(
body.into(),
comments.ranges(),
f.context().source(),
) == CallChainLayout::Fluent
{
parenthesize_if_expands(&unparenthesized).fmt(f)
} else {
let unparenthesized = unparenthesized.memoized();
if unparenthesized.inspect(f)?.will_break() {
expand_parent().fmt(f)?;
}
best_fitting![
// body all flat
unparenthesized,
// body expanded
group(&unparenthesized).should_expand(true),
// parenthesized
format_args![token("("), block_indent(&unparenthesized), token(")")]
]
.fmt(f)
}
}
// For other cases with their own parentheses, such as lists, sets, dicts, tuples,
// etc., we can just format the body directly. Their own formatting results in the
// lambda being formatted well too. For example:
//
// ```py
// lambda xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz: [xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz]
// ```
//
// gets formatted as:
//
// ```py
// lambda xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz: [
// xxxxxxxxxxxxxxxxxxxx,
// yyyyyyyyyyyyyyyyyyyy,
// zzzzzzzzzzzzzzzzzzzz
// ]
// ```
else if has_own_parentheses(body, f.context()).is_some() {
body.format().fmt(f)
}
// Finally, for expressions without their own parentheses, use
// `parenthesize_if_expands` to add parentheses around the body, only if it expands
// across multiple lines. The `Parentheses::Never` here also removes unnecessary
// parentheses around lambda bodies that fit on one line. For example:
//
// ```py
// lambda xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz: xxxxxxxxxxxxxxxxxxxx + yyyyyyyyyyyyyyyyyyyy + zzzzzzzzzzzzzzzzzzzz
// ```
//
// is formatted as:
//
// ```py
// lambda xxxxxxxxxxxxxxxxxxxx, yyyyyyyyyyyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzz: (
// xxxxxxxxxxxxxxxxxxxx + yyyyyyyyyyyyyyyyyyyy + zzzzzzzzzzzzzzzzzzzz
// )
// ```
//
// while
//
// ```py
// lambda xxxxxxxxxxxxxxxxxxxx: (xxxxxxxxxxxxxxxxxxxx + 1)
// ```
//
// is formatted as:
//
// ```py
// lambda xxxxxxxxxxxxxxxxxxxx: xxxxxxxxxxxxxxxxxxxx + 1
// ```
else {
parenthesize_if_expands(&body.format().with_options(Parentheses::Never)).fmt(f)
}
}
}

View File

@ -52,3 +52,10 @@ pub(crate) const fn is_avoid_parens_for_long_as_captures_enabled(
) -> bool { ) -> bool {
context.is_preview() context.is_preview()
} }
/// Returns `true` if the
/// [`parenthesize_lambda_bodies`](https://github.com/astral-sh/ruff/pull/21385) preview style is
/// enabled.
pub(crate) const fn is_parenthesize_lambda_bodies_enabled(context: &PyFormatContext) -> bool {
context.is_preview()
}

View File

@ -9,6 +9,7 @@ use crate::comments::{
Comments, LeadingDanglingTrailingComments, SourceComment, trailing_comments, Comments, LeadingDanglingTrailingComments, SourceComment, trailing_comments,
}; };
use crate::context::{NodeLevel, WithNodeLevel}; use crate::context::{NodeLevel, WithNodeLevel};
use crate::expression::expr_lambda::ExprLambdaLayout;
use crate::expression::parentheses::{ use crate::expression::parentheses::{
NeedsParentheses, OptionalParentheses, Parentheses, Parenthesize, is_expression_parenthesized, NeedsParentheses, OptionalParentheses, Parentheses, Parenthesize, is_expression_parenthesized,
optional_parentheses, optional_parentheses,
@ -18,6 +19,7 @@ use crate::expression::{
maybe_parenthesize_expression, maybe_parenthesize_expression,
}; };
use crate::other::interpolated_string::InterpolatedStringLayout; use crate::other::interpolated_string::InterpolatedStringLayout;
use crate::preview::is_parenthesize_lambda_bodies_enabled;
use crate::statement::trailing_semicolon; use crate::statement::trailing_semicolon;
use crate::string::StringLikeExtensions; use crate::string::StringLikeExtensions;
use crate::string::implicit::{ use crate::string::implicit::{
@ -303,12 +305,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
&& format_implicit_flat.is_none() && format_implicit_flat.is_none()
&& format_interpolated_string.is_none() && format_interpolated_string.is_none()
{ {
return maybe_parenthesize_expression( return maybe_parenthesize_value(value, *statement).fmt(f);
value,
*statement,
Parenthesize::IfBreaks,
)
.fmt(f);
} }
let comments = f.context().comments().clone(); let comments = f.context().comments().clone();
@ -586,11 +583,7 @@ impl Format<PyFormatContext<'_>> for FormatStatementsLastExpression<'_> {
space(), space(),
operator, operator,
space(), space(),
maybe_parenthesize_expression( maybe_parenthesize_value(value, *statement)
value,
*statement,
Parenthesize::IfBreaks
)
] ]
); );
} }
@ -1369,3 +1362,32 @@ fn is_attribute_with_parenthesized_value(target: &Expr, context: &PyFormatContex
_ => false, _ => false,
} }
} }
/// Like [`maybe_parenthesize_expression`] but with special handling for lambdas in preview.
fn maybe_parenthesize_value<'a>(
expression: &'a Expr,
parent: AnyNodeRef<'a>,
) -> MaybeParenthesizeValue<'a> {
MaybeParenthesizeValue { expression, parent }
}
struct MaybeParenthesizeValue<'a> {
expression: &'a Expr,
parent: AnyNodeRef<'a>,
}
impl Format<PyFormatContext<'_>> for MaybeParenthesizeValue<'_> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let MaybeParenthesizeValue { expression, parent } = self;
if is_parenthesize_lambda_bodies_enabled(f.context())
&& let Expr::Lambda(lambda) = expression
&& !f.context().comments().has_leading(lambda)
{
parenthesize_if_expands(&lambda.format().with_options(ExprLambdaLayout::Assignment))
.fmt(f)
} else {
maybe_parenthesize_expression(expression, *parent, Parenthesize::IfBreaks).fmt(f)
}
}
}

View File

@ -906,11 +906,10 @@ x = {
-) -)
+string_with_escaped_nameescape = "........................................................................... \\N{LAO KO LA}" +string_with_escaped_nameescape = "........................................................................... \\N{LAO KO LA}"
-msg = lambda x: ( msg = lambda x: (
- f"this is a very very very very long lambda value {x} that doesn't fit on a" - f"this is a very very very very long lambda value {x} that doesn't fit on a"
- " single line" - " single line"
+msg = ( + f"this is a very very very very long lambda value {x} that doesn't fit on a single line"
+ lambda x: f"this is a very very very very long lambda value {x} that doesn't fit on a single line"
) )
dict_with_lambda_values = { dict_with_lambda_values = {
@ -1403,8 +1402,8 @@ string_with_escaped_nameescape = "..............................................
string_with_escaped_nameescape = "........................................................................... \\N{LAO KO LA}" string_with_escaped_nameescape = "........................................................................... \\N{LAO KO LA}"
msg = ( msg = lambda x: (
lambda x: f"this is a very very very very long lambda value {x} that doesn't fit on a single line" f"this is a very very very very long lambda value {x} that doesn't fit on a single line"
) )
dict_with_lambda_values = { dict_with_lambda_values = {

View File

@ -375,7 +375,7 @@ a = b if """
# Another use case # Another use case
data = yaml.load("""\ data = yaml.load("""\
a: 1 a: 1
@@ -77,19 +106,23 @@ @@ -77,10 +106,12 @@
b: 2 b: 2
""", """,
) )
@ -390,19 +390,7 @@ a = b if """
MULTILINE = """ MULTILINE = """
foo foo
""".replace("\n", "") @@ -156,16 +187,24 @@
-generated_readme = lambda project_name: """
+generated_readme = (
+ lambda project_name: """
{}
<Add content here!>
""".strip().format(project_name)
+)
parser.usage += """
Custom extra help summary.
@@ -156,16 +189,24 @@
10 LOAD_CONST 0 (None) 10 LOAD_CONST 0 (None)
12 RETURN_VALUE 12 RETURN_VALUE
""" % (_C.__init__.__code__.co_firstlineno + 1,) """ % (_C.__init__.__code__.co_firstlineno + 1,)
@ -433,7 +421,7 @@ a = b if """
[ [
"""cow """cow
moos""", moos""",
@@ -206,7 +247,9 @@ @@ -206,7 +245,9 @@
"c" "c"
) )
@ -444,7 +432,7 @@ a = b if """
assert some_var == expected_result, """ assert some_var == expected_result, """
test test
@@ -224,10 +267,8 @@ @@ -224,10 +265,8 @@
"""Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx """Sxxxxxxx xxxxxxxx, xxxxxxx xx xxxxxxxxx
xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx""" xxxxxxxxxxxxx xxxxxxx xxxxxxxxx xxx-xxxxxxxxxx xxxxxx xx xxx-xxxxxx"""
), ),
@ -457,7 +445,7 @@ a = b if """
}, },
} }
@@ -246,14 +287,12 @@ @@ -246,14 +285,12 @@
a a
a""" a"""
), ),
@ -597,13 +585,11 @@ data = yaml.load(
MULTILINE = """ MULTILINE = """
foo foo
""".replace("\n", "") """.replace("\n", "")
generated_readme = ( generated_readme = lambda project_name: """
lambda project_name: """
{} {}
<Add content here!> <Add content here!>
""".strip().format(project_name) """.strip().format(project_name)
)
parser.usage += """ parser.usage += """
Custom extra help summary. Custom extra help summary.

View File

@ -1,7 +1,6 @@
--- ---
source: crates/ruff_python_formatter/tests/fixtures.rs source: crates/ruff_python_formatter/tests/fixtures.rs
input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/multiline_string_deviations.py input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/multiline_string_deviations.py
snapshot_kind: text
--- ---
## Input ## Input
```python ```python
@ -106,3 +105,22 @@ generated_readme = (
""".strip().format(project_name) """.strip().format(project_name)
) )
``` ```
## Preview changes
```diff
--- Stable
+++ Preview
@@ -44,10 +44,8 @@
# this by changing `Lambda::needs_parentheses` to return `BestFit` but it causes
# issues when the lambda has comments.
# Let's keep this as a known deviation for now.
-generated_readme = (
- lambda project_name: """
+generated_readme = lambda project_name: """
{}
<Add content here!>
""".strip().format(project_name)
-)
```