diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/fluent.options.json b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fluent.options.json new file mode 100644 index 0000000000..f69dec6066 --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fluent.options.json @@ -0,0 +1 @@ +[{"line_width":8}] diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/fluent.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fluent.py new file mode 100644 index 0000000000..0b0b76f1dd --- /dev/null +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/fluent.py @@ -0,0 +1,35 @@ +# Fixtures for fluent formatting of call chains +# Note that `fluent.options.json` sets line width to 8 + + +x = a.b() + +x = a.b().c() + +x = a.b().c().d + +x = a.b.c.d().e() + +x = a.b.c().d.e().f.g() + +# Consecutive calls/subscripts are grouped together +# for the purposes of fluent formatting (though, as 2025.12.15, +# there may be a break inside of one of these +# calls/subscripts, but that is unrelated to the fluent format.) + +x = a()[0]().b().c() + +x = a.b()[0].c.d()[1]().e + +# Parentheses affect both where the root of the call +# chain is and how many calls we require before applying +# fluent formatting (just 1, in the presence of a parenthesized +# root, as of 2025.12.15.) + +x = (a).b() + +x = (a()).b() + +x = (a.b()).d.e() + +x = (a.b().d).e() diff --git a/crates/ruff_python_formatter/resources/test/fixtures/ruff/parentheses/call_chains.py b/crates/ruff_python_formatter/resources/test/fixtures/ruff/parentheses/call_chains.py index 0b49a1dd11..d221f4e1ed 100644 --- a/crates/ruff_python_formatter/resources/test/fixtures/ruff/parentheses/call_chains.py +++ b/crates/ruff_python_formatter/resources/test/fixtures/ruff/parentheses/call_chains.py @@ -216,3 +216,69 @@ max_message_id = ( .baz() ) +# Note in preview we split at `pl` which some +# folks may dislike. (Similarly with common +# `np` and `pd` invocations). +# +# This is because we cannot reliably predict, +# just from syntax, whether a short identifier +# is being used as a 'namespace' or as an 'object'. +# +# As of 2025.12.15, we do not indent methods in +# fluent formatting. If we ever decide to do so, +# it may make sense to special case call chain roots +# that are shorter than the indent-width (like Prettier does). +# This would have the benefit of handling these common +# two-letter aliases for libraries. + + +expr = ( + pl.scan_parquet("/data/pypi-parquet/*.parquet") + .filter( + [ + pl.col("path").str.contains( + r"\.(asm|c|cc|cpp|cxx|h|hpp|rs|[Ff][0-9]{0,2}(?:or)?|go)$" + ), + ~pl.col("path").str.contains(r"(^|/)test(|s|ing)"), + ~pl.col("path").str.contains("/site-packages/", literal=True), + ] + ) + .with_columns( + month=pl.col("uploaded_on").dt.truncate("1mo"), + ext=pl.col("path") + .str.extract(pattern=r"\.([a-z0-9]+)$", group_index=1) + .str.replace_all(pattern=r"cxx|cpp|cc|c|hpp|h", value="C/C++") + .str.replace_all(pattern="^f.*$", value="Fortran") + .str.replace("rs", "Rust", literal=True) + .str.replace("go", "Go", literal=True) + .str.replace("asm", "Assembly", literal=True) + .replace({"": None}), + ) + .group_by(["month", "ext"]) + .agg(project_count=pl.col("project_name").n_unique()) + .drop_nulls(["ext"]) + .sort(["month", "project_count"], descending=True) +) + +def indentation_matching_for_loop_in_preview(): + if make_this: + if more_nested_because_line_length: + identical_hidden_layer_sizes = all( + current_hidden_layer_sizes == first_hidden_layer_sizes + for current_hidden_layer_sizes in self.component_config[ + HIDDEN_LAYERS_SIZES + ].values().attr + ) + +def indentation_matching_walrus_in_preview(): + if make_this: + if more_nested_because_line_length: + with self.read_ctx(book_type) as cursor: + if (entry_count := len(names := cursor.execute( + 'SELECT name FROM address_book WHERE address=?', + (address,), + ).fetchall().some_attr)) == 0 or len(set(names)) > 1: + return + +# behavior with parenthesized roots +x = (aaaaaaaaaaaaaaaaaaaaaa).bbbbbbbbbbbbbbbbbbb.cccccccccccccccccccccccc().dddddddddddddddddddddddd().eeeeeeeeeeee diff --git a/crates/ruff_python_formatter/src/expression/expr_attribute.rs b/crates/ruff_python_formatter/src/expression/expr_attribute.rs index 781b8b4601..ca88313b19 100644 --- a/crates/ruff_python_formatter/src/expression/expr_attribute.rs +++ b/crates/ruff_python_formatter/src/expression/expr_attribute.rs @@ -10,6 +10,7 @@ use crate::expression::parentheses::{ NeedsParentheses, OptionalParentheses, Parentheses, is_expression_parenthesized, }; use crate::prelude::*; +use crate::preview::is_fluent_layout_split_first_call_enabled; #[derive(Default)] pub struct FormatExprAttribute { @@ -47,20 +48,26 @@ impl FormatNodeRule for FormatExprAttribute { ) }; - if call_chain_layout == CallChainLayout::Fluent { + if call_chain_layout.is_fluent() { if parenthesize_value { // Don't propagate the call chain layout. value.format().with_options(Parentheses::Always).fmt(f)?; } else { match value.as_ref() { Expr::Attribute(expr) => { - expr.format().with_options(call_chain_layout).fmt(f)?; + expr.format() + .with_options(call_chain_layout.transition_after_attribute()) + .fmt(f)?; } Expr::Call(expr) => { - expr.format().with_options(call_chain_layout).fmt(f)?; + expr.format() + .with_options(call_chain_layout.transition_after_attribute()) + .fmt(f)?; } Expr::Subscript(expr) => { - expr.format().with_options(call_chain_layout).fmt(f)?; + expr.format() + .with_options(call_chain_layout.transition_after_attribute()) + .fmt(f)?; } _ => { value.format().with_options(Parentheses::Never).fmt(f)?; @@ -105,8 +112,30 @@ impl FormatNodeRule for FormatExprAttribute { // Allow the `.` on its own line if this is a fluent call chain // and the value either requires parenthesizing or is a call or subscript expression // (it's a fluent chain but not the first element). - else if call_chain_layout == CallChainLayout::Fluent { - if parenthesize_value || value.is_call_expr() || value.is_subscript_expr() { + // + // In preview we also break _at_ the first call in the chain. + // For example: + // + // ```diff + // # stable formatting vs. preview + // x = ( + // - df.merge() + // + df + // + .merge() + // .groupby() + // .agg() + // .filter() + // ) + // ``` + else if call_chain_layout.is_fluent() { + if parenthesize_value + || value.is_call_expr() + || value.is_subscript_expr() + // Remember to update the doc-comment above when + // stabilizing this behavior. + || (is_fluent_layout_split_first_call_enabled(f.context()) + && call_chain_layout.is_first_call_like()) + { soft_line_break().fmt(f)?; } } @@ -148,8 +177,8 @@ impl FormatNodeRule for FormatExprAttribute { ) }); - let is_call_chain_root = self.call_chain_layout == CallChainLayout::Default - && call_chain_layout == CallChainLayout::Fluent; + let is_call_chain_root = + self.call_chain_layout == CallChainLayout::Default && call_chain_layout.is_fluent(); if is_call_chain_root { write!(f, [group(&format_inner)]) } else { @@ -169,7 +198,8 @@ impl NeedsParentheses for ExprAttribute { self.into(), context.comments().ranges(), context.source(), - ) == CallChainLayout::Fluent + ) + .is_fluent() { OptionalParentheses::Multiline } else if context.comments().has_dangling(self) { diff --git a/crates/ruff_python_formatter/src/expression/expr_call.rs b/crates/ruff_python_formatter/src/expression/expr_call.rs index a5c3227a7d..135ade2266 100644 --- a/crates/ruff_python_formatter/src/expression/expr_call.rs +++ b/crates/ruff_python_formatter/src/expression/expr_call.rs @@ -47,7 +47,10 @@ impl FormatNodeRule for FormatExprCall { func.format().with_options(Parentheses::Always).fmt(f) } else { match func.as_ref() { - Expr::Attribute(expr) => expr.format().with_options(call_chain_layout).fmt(f), + Expr::Attribute(expr) => expr + .format() + .with_options(call_chain_layout.decrement_call_like_count()) + .fmt(f), Expr::Call(expr) => expr.format().with_options(call_chain_layout).fmt(f), Expr::Subscript(expr) => expr.format().with_options(call_chain_layout).fmt(f), _ => func.format().with_options(Parentheses::Never).fmt(f), @@ -67,9 +70,7 @@ impl FormatNodeRule for FormatExprCall { // queryset.distinct().order_by(field.name).values_list(field_name_flat_long_long=True) // ) // ``` - if call_chain_layout == CallChainLayout::Fluent - && self.call_chain_layout == CallChainLayout::Default - { + if call_chain_layout.is_fluent() && self.call_chain_layout == CallChainLayout::Default { group(&fmt_func).fmt(f) } else { fmt_func.fmt(f) @@ -87,7 +88,8 @@ impl NeedsParentheses for ExprCall { self.into(), context.comments().ranges(), context.source(), - ) == CallChainLayout::Fluent + ) + .is_fluent() { OptionalParentheses::Multiline } else if context.comments().has_dangling(self) { diff --git a/crates/ruff_python_formatter/src/expression/expr_lambda.rs b/crates/ruff_python_formatter/src/expression/expr_lambda.rs index faab6ef8c5..63e8aa6d97 100644 --- a/crates/ruff_python_formatter/src/expression/expr_lambda.rs +++ b/crates/ruff_python_formatter/src/expression/expr_lambda.rs @@ -397,7 +397,8 @@ impl Format> for FormatBody<'_> { body.into(), comments.ranges(), f.context().source(), - ) == CallChainLayout::Fluent + ) + .is_fluent() { parenthesize_if_expands(&unparenthesized).fmt(f) } else { diff --git a/crates/ruff_python_formatter/src/expression/expr_subscript.rs b/crates/ruff_python_formatter/src/expression/expr_subscript.rs index ce3aaf1f69..c9595c4a26 100644 --- a/crates/ruff_python_formatter/src/expression/expr_subscript.rs +++ b/crates/ruff_python_formatter/src/expression/expr_subscript.rs @@ -51,7 +51,10 @@ impl FormatNodeRule for FormatExprSubscript { value.format().with_options(Parentheses::Always).fmt(f) } else { match value.as_ref() { - Expr::Attribute(expr) => expr.format().with_options(call_chain_layout).fmt(f), + Expr::Attribute(expr) => expr + .format() + .with_options(call_chain_layout.decrement_call_like_count()) + .fmt(f), Expr::Call(expr) => expr.format().with_options(call_chain_layout).fmt(f), Expr::Subscript(expr) => expr.format().with_options(call_chain_layout).fmt(f), _ => value.format().with_options(Parentheses::Never).fmt(f), @@ -71,8 +74,8 @@ impl FormatNodeRule for FormatExprSubscript { .fmt(f) }); - let is_call_chain_root = self.call_chain_layout == CallChainLayout::Default - && call_chain_layout == CallChainLayout::Fluent; + let is_call_chain_root = + self.call_chain_layout == CallChainLayout::Default && call_chain_layout.is_fluent(); if is_call_chain_root { write!(f, [group(&format_inner)]) } else { @@ -92,7 +95,8 @@ impl NeedsParentheses for ExprSubscript { self.into(), context.comments().ranges(), context.source(), - ) == CallChainLayout::Fluent + ) + .is_fluent() { OptionalParentheses::Multiline } else if is_expression_parenthesized( diff --git a/crates/ruff_python_formatter/src/expression/mod.rs b/crates/ruff_python_formatter/src/expression/mod.rs index a320a1edf5..4b6c159fe2 100644 --- a/crates/ruff_python_formatter/src/expression/mod.rs +++ b/crates/ruff_python_formatter/src/expression/mod.rs @@ -876,6 +876,22 @@ impl<'a> First<'a> { /// ) /// ).all() /// ``` +/// +/// In [`preview`](crate::preview::is_fluent_layout_split_first_call_enabled), we also track the position of the leftmost call or +/// subscript on an attribute in the chain and break just before the dot. +/// +/// So, for example, the right-hand summand in the above expression +/// would get formatted as: +/// ```python +/// Blog.objects +/// .filter( +/// entry__headline__contains="McCartney", +/// ) +/// .limit_results[:10] +/// .filter( +/// entry__pub_date__year=2010, +/// ) +/// ``` #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub enum CallChainLayout { /// The root of a call chain @@ -883,19 +899,149 @@ pub enum CallChainLayout { Default, /// A nested call chain element that uses fluent style. - Fluent, + Fluent(AttributeState), /// A nested call chain element not using fluent style. NonFluent, } +/// Records information about the current position within +/// a call chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttributeState { + /// Stores the number of calls or subscripts + /// to the left of the current position in a chain. + /// + /// Consecutive calls/subscripts on a single + /// object only count once. For example, if we are at + /// `c` in `a.b()[0]()().c()` then this number would be 1. + /// + /// Caveat: If the root of the chain is parenthesized, + /// it contributes +1 to this count, even if it is not + /// a call or subscript. But the name + /// `CallLikeOrParenthesizedRootPreceding` + /// is a tad unwieldy, and this also rarely occurs. + CallLikePreceding(u32), + /// Indicates that we are at the first called or + /// subscripted object in the chain + /// + /// For example, if we are at `b` in `a.b()[0]()().c()` + FirstCallLike, + /// Indicates that we are to the left of the first + /// called or subscripted object in the chain, and therefore + /// need not break. + /// + /// For example, if we are at `a` in `a.b()[0]()().c()` + BeforeFirstCallLike, +} + impl CallChainLayout { + /// Returns new state decreasing count of remaining calls/subscripts + /// to traverse, or the state `FirstCallOrSubscript`, as appropriate. + #[must_use] + pub(crate) fn decrement_call_like_count(self) -> Self { + match self { + Self::Fluent(AttributeState::CallLikePreceding(x)) => { + if x > 1 { + // Recall that we traverse call chains from right to + // left. So after moving from a call/subscript into + // an attribute, we _decrease_ the count of + // _remaining_ calls or subscripts to the left of our + // current position. + Self::Fluent(AttributeState::CallLikePreceding(x - 1)) + } else { + Self::Fluent(AttributeState::FirstCallLike) + } + } + _ => self, + } + } + + /// Returns with state change + /// `FirstCallOrSubscript` -> `BeforeFirstCallOrSubscript` + /// and otherwise returns unchanged. + #[must_use] + pub(crate) fn transition_after_attribute(self) -> Self { + match self { + Self::Fluent(AttributeState::FirstCallLike) => { + Self::Fluent(AttributeState::BeforeFirstCallLike) + } + _ => self, + } + } + + pub(crate) fn is_first_call_like(self) -> bool { + matches!(self, Self::Fluent(AttributeState::FirstCallLike)) + } + + /// Returns either `Fluent` or `NonFluent` depending on a + /// heuristic computed for the whole chain. + /// + /// Explicitly, the criterion to return `Fluent` is + /// as follows: + /// + /// 1. Beginning from the right (i.e. the `expr` itself), + /// traverse inwards past calls, subscripts, and attribute + /// expressions until we meet the first expression that is + /// either none of these or else is parenthesized. This will + /// be the _root_ of the call chain. + /// 2. Count the number of _attribute values_ that are _called + /// or subscripted_ in the chain (note that this includes the + /// root but excludes the rightmost attribute in the chain since + /// it is not the _value_ of some attribute). + /// 3. If the root is parenthesized, add 1 to that value. + /// 4. If the total is at least 2, return `Fluent`. Otherwise + /// return `NonFluent` pub(crate) fn from_expression( mut expr: ExprRef, comment_ranges: &CommentRanges, source: &str, ) -> Self { - let mut attributes_after_parentheses = 0; + // TODO(dylan): Once the fluent layout preview style is + // stabilized, see if it is possible to simplify some of + // the logic around parenthesized roots. (While supporting + // both styles it is more difficult to do this.) + + // Count of attribute _values_ which are called or + // subscripted, after the leftmost parenthesized + // value. + // + // Examples: + // ``` + // # Count of 3 - notice that .d() + // # does not contribute + // a().b().c[0]()().d() + // # Count of 2 - notice that a() + // # does not contribute + // (a()).b().c[0].d + // ``` + let mut computed_attribute_values_after_parentheses = 0; + + // Similar to the above, but instead looks at all calls + // and subscripts rather than looking only at those on + // _attribute values_. So this count can differ from the + // above. + // + // Examples of `computed_attribute_values_after_parentheses` vs + // `call_like_count`: + // + // a().b ---> 1 vs 1 + // a.b().c --> 1 vs 1 + // a.b() ---> 0 vs 1 + let mut call_like_count = 0; + + // Going from right to left, we traverse calls, subscripts, + // and attributes until we get to an expression of a different + // kind _or_ to a parenthesized expression. This records + // the case where we end the traversal at a parenthesized expression. + // + // In these cases, the inferred semantics of the chain are different. + // We interpret this as the user indicating: + // "this parenthesized value is the object of interest and we are + // doing transformations on it". This increases our confidence that + // this should be fluently formatted, and also means we should make + // our first break after this value. + let mut root_value_parenthesized = false; loop { match expr { ExprRef::Attribute(ast::ExprAttribute { value, .. }) => { @@ -907,10 +1053,10 @@ impl CallChainLayout { // ``` if is_expression_parenthesized(value.into(), comment_ranges, source) { // `(a).b`. We preserve these parentheses so don't recurse - attributes_after_parentheses += 1; + root_value_parenthesized = true; break; } else if matches!(value.as_ref(), Expr::Call(_) | Expr::Subscript(_)) { - attributes_after_parentheses += 1; + computed_attribute_values_after_parentheses += 1; } expr = ExprRef::from(value.as_ref()); @@ -925,31 +1071,68 @@ impl CallChainLayout { // ``` ExprRef::Call(ast::ExprCall { func: inner, .. }) | ExprRef::Subscript(ast::ExprSubscript { value: inner, .. }) => { + // We preserve these parentheses so don't recurse + // e.g. (a)[0].x().y().z() + // ^stop here + if is_expression_parenthesized(inner.into(), comment_ranges, source) { + break; + } + + // Accumulate the `call_like_count`, but we only + // want to count things like `a()[0]()()` once. + if !inner.is_call_expr() && !inner.is_subscript_expr() { + call_like_count += 1; + } + expr = ExprRef::from(inner.as_ref()); } _ => { - // We to format the following in fluent style: - // ``` - // f2 = (a).w().t(1,) - // ^ expr - // ``` - if is_expression_parenthesized(expr, comment_ranges, source) { - attributes_after_parentheses += 1; - } - break; } } - - // We preserve these parentheses so don't recurse - if is_expression_parenthesized(expr, comment_ranges, source) { - break; - } } - if attributes_after_parentheses < 2 { + + if computed_attribute_values_after_parentheses + u32::from(root_value_parenthesized) < 2 { CallChainLayout::NonFluent } else { - CallChainLayout::Fluent + CallChainLayout::Fluent(AttributeState::CallLikePreceding( + // We count a parenthesized root value as an extra + // call for the purposes of tracking state. + // + // The reason is that, in this case, we want the first + // "special" break to happen right after the root, as + // opposed to right after the first called/subscripted + // attribute. + // + // For example: + // + // ``` + // (object_of_interest) + // .data.filter() + // .agg() + // .etc() + // ``` + // + // instead of (in preview): + // + // ``` + // (object_of_interest) + // .data + // .filter() + // .etc() + // ``` + // + // For comparison, if we didn't have parentheses around + // the root, we want (and get, in preview): + // + // ``` + // object_of_interest.data + // .filter() + // .agg() + // .etc() + // ``` + call_like_count + u32::from(root_value_parenthesized), + )) } } @@ -972,9 +1155,13 @@ impl CallChainLayout { CallChainLayout::NonFluent } } - layout @ (CallChainLayout::Fluent | CallChainLayout::NonFluent) => layout, + layout @ (CallChainLayout::Fluent(_) | CallChainLayout::NonFluent) => layout, } } + + pub(crate) fn is_fluent(self) -> bool { + matches!(self, CallChainLayout::Fluent(_)) + } } #[derive(Debug, Copy, Clone, PartialEq, Eq)] diff --git a/crates/ruff_python_formatter/src/preview.rs b/crates/ruff_python_formatter/src/preview.rs index 62b6b90033..ff94f66081 100644 --- a/crates/ruff_python_formatter/src/preview.rs +++ b/crates/ruff_python_formatter/src/preview.rs @@ -59,3 +59,10 @@ pub(crate) const fn is_avoid_parens_for_long_as_captures_enabled( pub(crate) const fn is_parenthesize_lambda_bodies_enabled(context: &PyFormatContext) -> bool { context.is_preview() } + +/// Returns `true` if the +/// [`fluent_layout_split_first_call`](https://github.com/astral-sh/ruff/pull/21369) preview +/// style is enabled. +pub(crate) const fn is_fluent_layout_split_first_call_enabled(context: &PyFormatContext) -> bool { + context.is_preview() +} diff --git a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_dict_values.py.snap b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_dict_values.py.snap index 93f28b8669..f021bad61c 100644 --- a/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_dict_values.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/black_compatibility@cases__preview_long_dict_values.py.snap @@ -192,7 +192,7 @@ class Random: } x = { "foobar": (123) + 456, -@@ -97,24 +94,20 @@ +@@ -97,24 +94,21 @@ my_dict = { @@ -221,13 +221,14 @@ class Random: - .second_call() - .third_call(some_args="some value") - ) -+ "a key in my dict": MyClass.some_attribute.first_call() ++ "a key in my dict": MyClass.some_attribute ++ .first_call() + .second_call() + .third_call(some_args="some value") } { -@@ -139,17 +132,17 @@ +@@ -139,17 +133,17 @@ class Random: def func(): @@ -363,7 +364,8 @@ my_dict = { / 100000.0 } my_dict = { - "a key in my dict": MyClass.some_attribute.first_call() + "a key in my dict": MyClass.some_attribute + .first_call() .second_call() .third_call(some_args="some value") } diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__await.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__await.py.snap index de396cccfb..63b8890e04 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__await.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__await.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/await.py -snapshot_kind: text --- ## Input ```python @@ -142,3 +141,20 @@ test_data = await ( .to_list() ) ``` + + +## Preview changes +```diff +--- Stable ++++ Preview +@@ -65,7 +65,8 @@ + + # https://github.com/astral-sh/ruff/issues/8644 + test_data = await ( +- Stream.from_async(async_data) ++ Stream ++ .from_async(async_data) + .flat_map_async() + .map() + .filter_async(is_valid_data) +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__call.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__call.py.snap index 6b5d7bfa99..6feb06a6ac 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__call.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__call.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/call.py -snapshot_kind: text --- ## Input ```python @@ -557,3 +556,20 @@ result = ( result = (object[complicate_caller])("argument").a["b"].test(argument) ``` + + +## Preview changes +```diff +--- Stable ++++ Preview +@@ -57,7 +57,8 @@ + + # Call chains/fluent interface (https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#call-chains) + result = ( +- session.query(models.Customer.id) ++ session ++ .query(models.Customer.id) + .filter( + models.Customer.account_id == 10000, + models.Customer.email == "user@example.org", +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__lambda.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__lambda.py.snap index 82ee1639d7..393995f523 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__lambda.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__lambda.py.snap @@ -2155,7 +2155,7 @@ transform = ( ), param( lambda left, right: ( -@@ -471,9 +463,9 @@ +@@ -471,15 +463,16 @@ ), param(lambda left, right: ibis.timestamp("2017-04-01").cast(dt.date)), param( @@ -2168,7 +2168,15 @@ transform = ( ), # This is too long on one line in the lambda body and gets wrapped # inside the body. -@@ -507,16 +499,18 @@ + param( + lambda left, right: ( +- ibis.timestamp("2017-04-01") ++ ibis ++ .timestamp("2017-04-01") + .cast(dt.date) + .between(left, right) + .between(left, right) +@@ -507,16 +500,18 @@ ] # adds parentheses around the body @@ -2190,7 +2198,7 @@ transform = ( lambda x, y, z: ( x + y + z -@@ -527,7 +521,7 @@ +@@ -527,7 +522,7 @@ x + y + z # trailing eol body ) @@ -2199,7 +2207,7 @@ transform = ( lambda x, y, z: ( # leading body -@@ -539,21 +533,23 @@ +@@ -539,21 +534,23 @@ ) ( @@ -2233,7 +2241,7 @@ transform = ( # dangling header comment source_bucket if name == source_bucket_name -@@ -561,8 +557,7 @@ +@@ -561,8 +558,7 @@ ) ( @@ -2243,7 +2251,7 @@ transform = ( source_bucket if name == source_bucket_name else storage.Bucket(mock_service, destination_bucket_name) -@@ -570,61 +565,70 @@ +@@ -570,61 +566,71 @@ ) ( @@ -2293,7 +2301,8 @@ transform = ( - .cast(dt.date) - .between(left, right) + lambda left, right: ( -+ ibis.timestamp("2017-04-01") # comment ++ ibis ++ .timestamp("2017-04-01") # comment + .cast(dt.date) + .between(left, right) + ) @@ -2346,7 +2355,7 @@ transform = ( ) ( -@@ -637,27 +641,31 @@ +@@ -637,27 +643,31 @@ ( lambda # comment @@ -2386,7 +2395,7 @@ transform = ( ) ( -@@ -672,25 +680,28 @@ +@@ -672,25 +682,28 @@ ) ( @@ -2427,7 +2436,7 @@ transform = ( ) ( -@@ -698,9 +709,9 @@ +@@ -698,9 +711,9 @@ # comment 1 *ergs, # comment 2 @@ -2440,7 +2449,7 @@ transform = ( ) ( -@@ -708,19 +719,20 @@ +@@ -708,19 +721,20 @@ # 2 left, # 3 # 4 @@ -2471,7 +2480,7 @@ transform = ( ) ) ) -@@ -738,48 +750,52 @@ +@@ -738,48 +752,52 @@ foo( lambda from_ts, # but still wrap the body if it gets too long to_ts, @@ -2548,7 +2557,7 @@ transform = ( ) ( -@@ -828,8 +844,7 @@ +@@ -828,8 +846,7 @@ ) ( diff --git a/crates/ruff_python_formatter/tests/snapshots/format@expression__split_empty_brackets.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@expression__split_empty_brackets.py.snap index 65fa97cca4..ccb7c95aa6 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@expression__split_empty_brackets.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@expression__split_empty_brackets.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/expression/split_empty_brackets.py -snapshot_kind: text --- ## Input ```python diff --git a/crates/ruff_python_formatter/tests/snapshots/format@fluent.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@fluent.py.snap new file mode 100644 index 0000000000..73213398d5 --- /dev/null +++ b/crates/ruff_python_formatter/tests/snapshots/format@fluent.py.snap @@ -0,0 +1,163 @@ +--- +source: crates/ruff_python_formatter/tests/fixtures.rs +input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/fluent.py +--- +## Input +```python +# Fixtures for fluent formatting of call chains +# Note that `fluent.options.json` sets line width to 8 + + +x = a.b() + +x = a.b().c() + +x = a.b().c().d + +x = a.b.c.d().e() + +x = a.b.c().d.e().f.g() + +# Consecutive calls/subscripts are grouped together +# for the purposes of fluent formatting (though, as 2025.12.15, +# there may be a break inside of one of these +# calls/subscripts, but that is unrelated to the fluent format.) + +x = a()[0]().b().c() + +x = a.b()[0].c.d()[1]().e + +# Parentheses affect both where the root of the call +# chain is and how many calls we require before applying +# fluent formatting (just 1, in the presence of a parenthesized +# root, as of 2025.12.15.) + +x = (a).b() + +x = (a()).b() + +x = (a.b()).d.e() + +x = (a.b().d).e() +``` + +## Outputs +### Output 1 +``` +indent-style = space +line-width = 8 +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 = 3.10 +source_type = Python +``` + +```python +# Fixtures for fluent formatting of call chains +# Note that `fluent.options.json` sets line width to 8 + + +x = a.b() + +x = a.b().c() + +x = ( + a.b() + .c() + .d +) + +x = a.b.c.d().e() + +x = ( + a.b.c() + .d.e() + .f.g() +) + +# Consecutive calls/subscripts are grouped together +# for the purposes of fluent formatting (though, as 2025.12.15, +# there may be a break inside of one of these +# calls/subscripts, but that is unrelated to the fluent format.) + +x = ( + a()[ + 0 + ]() + .b() + .c() +) + +x = ( + a.b()[ + 0 + ] + .c.d()[ + 1 + ]() + .e +) + +# Parentheses affect both where the root of the call +# chain is and how many calls we require before applying +# fluent formatting (just 1, in the presence of a parenthesized +# root, as of 2025.12.15.) + +x = ( + a +).b() + +x = ( + a() +).b() + +x = ( + a.b() +).d.e() + +x = ( + a.b().d +).e() +``` + + +#### Preview changes +```diff +--- Stable ++++ Preview +@@ -7,7 +7,8 @@ + x = a.b().c() + + x = ( +- a.b() ++ a ++ .b() + .c() + .d + ) +@@ -15,7 +16,8 @@ + x = a.b.c.d().e() + + x = ( +- a.b.c() ++ a.b ++ .c() + .d.e() + .f.g() + ) +@@ -34,7 +36,8 @@ + ) + + x = ( +- a.b()[ ++ a ++ .b()[ + 0 + ] + .c.d()[ +``` diff --git a/crates/ruff_python_formatter/tests/snapshots/format@parentheses__call_chains.py.snap b/crates/ruff_python_formatter/tests/snapshots/format@parentheses__call_chains.py.snap index 4da30d3632..bbbb6de9df 100644 --- a/crates/ruff_python_formatter/tests/snapshots/format@parentheses__call_chains.py.snap +++ b/crates/ruff_python_formatter/tests/snapshots/format@parentheses__call_chains.py.snap @@ -1,7 +1,6 @@ --- source: crates/ruff_python_formatter/tests/fixtures.rs input_file: crates/ruff_python_formatter/resources/test/fixtures/ruff/parentheses/call_chains.py -snapshot_kind: text --- ## Input ```python @@ -223,6 +222,72 @@ max_message_id = ( .baz() ) +# Note in preview we split at `pl` which some +# folks may dislike. (Similarly with common +# `np` and `pd` invocations). +# +# This is because we cannot reliably predict, +# just from syntax, whether a short identifier +# is being used as a 'namespace' or as an 'object'. +# +# As of 2025.12.15, we do not indent methods in +# fluent formatting. If we ever decide to do so, +# it may make sense to special case call chain roots +# that are shorter than the indent-width (like Prettier does). +# This would have the benefit of handling these common +# two-letter aliases for libraries. + + +expr = ( + pl.scan_parquet("/data/pypi-parquet/*.parquet") + .filter( + [ + pl.col("path").str.contains( + r"\.(asm|c|cc|cpp|cxx|h|hpp|rs|[Ff][0-9]{0,2}(?:or)?|go)$" + ), + ~pl.col("path").str.contains(r"(^|/)test(|s|ing)"), + ~pl.col("path").str.contains("/site-packages/", literal=True), + ] + ) + .with_columns( + month=pl.col("uploaded_on").dt.truncate("1mo"), + ext=pl.col("path") + .str.extract(pattern=r"\.([a-z0-9]+)$", group_index=1) + .str.replace_all(pattern=r"cxx|cpp|cc|c|hpp|h", value="C/C++") + .str.replace_all(pattern="^f.*$", value="Fortran") + .str.replace("rs", "Rust", literal=True) + .str.replace("go", "Go", literal=True) + .str.replace("asm", "Assembly", literal=True) + .replace({"": None}), + ) + .group_by(["month", "ext"]) + .agg(project_count=pl.col("project_name").n_unique()) + .drop_nulls(["ext"]) + .sort(["month", "project_count"], descending=True) +) + +def indentation_matching_for_loop_in_preview(): + if make_this: + if more_nested_because_line_length: + identical_hidden_layer_sizes = all( + current_hidden_layer_sizes == first_hidden_layer_sizes + for current_hidden_layer_sizes in self.component_config[ + HIDDEN_LAYERS_SIZES + ].values().attr + ) + +def indentation_matching_walrus_in_preview(): + if make_this: + if more_nested_because_line_length: + with self.read_ctx(book_type) as cursor: + if (entry_count := len(names := cursor.execute( + 'SELECT name FROM address_book WHERE address=?', + (address,), + ).fetchall().some_attr)) == 0 or len(set(names)) > 1: + return + +# behavior with parenthesized roots +x = (aaaaaaaaaaaaaaaaaaaaaa).bbbbbbbbbbbbbbbbbbb.cccccccccccccccccccccccc().dddddddddddddddddddddddd().eeeeeeeeeeee ``` ## Output @@ -466,4 +531,237 @@ max_message_id = ( .sum() .baz() ) + +# Note in preview we split at `pl` which some +# folks may dislike. (Similarly with common +# `np` and `pd` invocations). +# +# This is because we cannot reliably predict, +# just from syntax, whether a short identifier +# is being used as a 'namespace' or as an 'object'. +# +# As of 2025.12.15, we do not indent methods in +# fluent formatting. If we ever decide to do so, +# it may make sense to special case call chain roots +# that are shorter than the indent-width (like Prettier does). +# This would have the benefit of handling these common +# two-letter aliases for libraries. + + +expr = ( + pl.scan_parquet("/data/pypi-parquet/*.parquet") + .filter( + [ + pl.col("path").str.contains( + r"\.(asm|c|cc|cpp|cxx|h|hpp|rs|[Ff][0-9]{0,2}(?:or)?|go)$" + ), + ~pl.col("path").str.contains(r"(^|/)test(|s|ing)"), + ~pl.col("path").str.contains("/site-packages/", literal=True), + ] + ) + .with_columns( + month=pl.col("uploaded_on").dt.truncate("1mo"), + ext=pl.col("path") + .str.extract(pattern=r"\.([a-z0-9]+)$", group_index=1) + .str.replace_all(pattern=r"cxx|cpp|cc|c|hpp|h", value="C/C++") + .str.replace_all(pattern="^f.*$", value="Fortran") + .str.replace("rs", "Rust", literal=True) + .str.replace("go", "Go", literal=True) + .str.replace("asm", "Assembly", literal=True) + .replace({"": None}), + ) + .group_by(["month", "ext"]) + .agg(project_count=pl.col("project_name").n_unique()) + .drop_nulls(["ext"]) + .sort(["month", "project_count"], descending=True) +) + + +def indentation_matching_for_loop_in_preview(): + if make_this: + if more_nested_because_line_length: + identical_hidden_layer_sizes = all( + current_hidden_layer_sizes == first_hidden_layer_sizes + for current_hidden_layer_sizes in self.component_config[ + HIDDEN_LAYERS_SIZES + ] + .values() + .attr + ) + + +def indentation_matching_walrus_in_preview(): + if make_this: + if more_nested_because_line_length: + with self.read_ctx(book_type) as cursor: + if ( + entry_count := len( + names := cursor.execute( + "SELECT name FROM address_book WHERE address=?", + (address,), + ) + .fetchall() + .some_attr + ) + ) == 0 or len(set(names)) > 1: + return + + +# behavior with parenthesized roots +x = ( + (aaaaaaaaaaaaaaaaaaaaaa) + .bbbbbbbbbbbbbbbbbbb.cccccccccccccccccccccccc() + .dddddddddddddddddddddddd() + .eeeeeeeeeeee +) +``` + + +## Preview changes +```diff +--- Stable ++++ Preview +@@ -21,7 +21,8 @@ + ) + + raise OsError("") from ( +- Blog.objects.filter( ++ Blog.objects ++ .filter( + entry__headline__contains="Lennon", + ) + .filter( +@@ -33,7 +34,8 @@ + ) + + raise OsError("sökdjffffsldkfjlhsakfjhalsökafhsöfdahsödfjösaaksjdllllllllllllll") from ( +- Blog.objects.filter( ++ Blog.objects ++ .filter( + entry__headline__contains="Lennon", + ) + .filter( +@@ -46,7 +48,8 @@ + + # Break only after calls and indexing + b1 = ( +- session.query(models.Customer.id) ++ session ++ .query(models.Customer.id) + .filter( + models.Customer.account_id == account_id, models.Customer.email == email_address + ) +@@ -54,7 +57,8 @@ + ) + + b2 = ( +- Blog.objects.filter( ++ Blog.objects ++ .filter( + entry__headline__contains="Lennon", + ) + .limit_results[:10] +@@ -70,7 +74,8 @@ + ).filter( + entry__pub_date__year=2008, + ) +- + Blog.objects.filter( ++ + Blog.objects ++ .filter( + entry__headline__contains="McCartney", + ) + .limit_results[:10] +@@ -89,7 +94,8 @@ + d11 = x.e().e().e() # + d12 = x.e().e().e() # + d13 = ( +- x.e() # ++ x ++ .e() # + .e() + .e() + ) +@@ -101,7 +107,8 @@ + + # Doesn't fit, fluent style + d3 = ( +- x.e() # ++ x ++ .e() # + .esadjkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk() + .esadjkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk() + ) +@@ -218,7 +225,8 @@ + + ( + ( +- df1_aaaaaaaaaaaa.merge() ++ df1_aaaaaaaaaaaa ++ .merge() + .groupby( + 1, + ) +@@ -228,7 +236,8 @@ + + ( + ( +- df1_aaaaaaaaaaaa.merge() ++ df1_aaaaaaaaaaaa ++ .merge() + .groupby( + 1, + ) +@@ -255,19 +264,19 @@ + + + expr = ( +- pl.scan_parquet("/data/pypi-parquet/*.parquet") +- .filter( +- [ +- pl.col("path").str.contains( +- r"\.(asm|c|cc|cpp|cxx|h|hpp|rs|[Ff][0-9]{0,2}(?:or)?|go)$" +- ), +- ~pl.col("path").str.contains(r"(^|/)test(|s|ing)"), +- ~pl.col("path").str.contains("/site-packages/", literal=True), +- ] +- ) ++ pl ++ .scan_parquet("/data/pypi-parquet/*.parquet") ++ .filter([ ++ pl.col("path").str.contains( ++ r"\.(asm|c|cc|cpp|cxx|h|hpp|rs|[Ff][0-9]{0,2}(?:or)?|go)$" ++ ), ++ ~pl.col("path").str.contains(r"(^|/)test(|s|ing)"), ++ ~pl.col("path").str.contains("/site-packages/", literal=True), ++ ]) + .with_columns( + month=pl.col("uploaded_on").dt.truncate("1mo"), +- ext=pl.col("path") ++ ext=pl ++ .col("path") + .str.extract(pattern=r"\.([a-z0-9]+)$", group_index=1) + .str.replace_all(pattern=r"cxx|cpp|cc|c|hpp|h", value="C/C++") + .str.replace_all(pattern="^f.*$", value="Fortran") +@@ -288,9 +297,8 @@ + if more_nested_because_line_length: + identical_hidden_layer_sizes = all( + current_hidden_layer_sizes == first_hidden_layer_sizes +- for current_hidden_layer_sizes in self.component_config[ +- HIDDEN_LAYERS_SIZES +- ] ++ for current_hidden_layer_sizes in self ++ .component_config[HIDDEN_LAYERS_SIZES] + .values() + .attr + ) +@@ -302,7 +310,8 @@ + with self.read_ctx(book_type) as cursor: + if ( + entry_count := len( +- names := cursor.execute( ++ names := cursor ++ .execute( + "SELECT name FROM address_book WHERE address=?", + (address,), + ) ``` diff --git a/docs/formatter.md b/docs/formatter.md index a80028d09e..f4c39edabb 100644 --- a/docs/formatter.md +++ b/docs/formatter.md @@ -501,6 +501,48 @@ If you want Ruff to split an f-string across multiple lines, ensure there's a li [self-documenting f-string]: https://realpython.com/python-f-strings/#self-documenting-expressions-for-debugging [configured quote style]: settings.md/#format_quote-style +#### Fluent layout for method chains + +At times, when developers write long chains of methods on an object, such as + +```python +x = df.filter(cond).agg(func).merge(other) +``` + +the intent is to perform a sequence of transformations or operations +on a fixed object of interest - in this example, the object `df`. +Assuming the assigned expression exceeds the `line-length`, this preview +style will format the above as: + +```python +x = ( + df + .filter(cond) + .agg(func) + .merge(other) +) +``` + +This deviates from the stable formatting, and also from Black, both +of which would produce: + + +```python +x = ( + df.filter(cond) + .agg(func) + .merge(other) +) +``` + +Both the stable and preview formatting are variants of something +called a **fluent layout**. + +In general, this preview style differs from the stable style +only at the first attribute that precedes +a call or subscript. The preview formatting breaks _before_ this attribute, +while the stable formatting breaks _after_ the call or subscript. + ## Sorting imports Currently, the Ruff formatter does not sort imports. In order to both sort imports and format,