Skip to content

stack-method-chains

stack-method-chains breaks a dotted method chain inside a parenthesis pair and hangs every link beneath the head's own dot, so the chain reads down one aligned column of dots.

Two triggers open the break, one reading the link count and one the width. The count trigger fires on a chain carrying more links than max-links, so a chain that fits the width still breaks once it carries enough stages to read as a pipeline. The width trigger fires where the joined single-line form crosses code-line-length from the column it sits at, which reaches a two-link chain the count cap leaves alone. A link is a .name(...) call, so a long dotted prefix ahead of a single call carries one link and stays where it sits, and a .name access that is not itself called shares the row of the link below it.

The head keeps the receiver together with its first call, because a bare receiver alone on a line carries no information, and each link below hangs at the receiver's own width past the head's indent. Where that width would put the dot column further out than max-shift allows, the chain takes the full split instead, standing the receiver alone and running every link flush beneath it at one indent. The cap is the same max-shift the alignment rules read.

The break only ever opens a chain and never rejoins one, so a chain already hung at its dots keeps that layout even where its joined form would fit, because a count trigger paired with a fit test would alternate forever, the count breaking the chain and the fit test rejoining it on the next pass.

The chain reuses a parenthesis pair the source already carries and settles in the same run that first opens it, leaving each link's argument list to reflow-calls and the collection inside it to reflow-collections. Both the count and the width are read against that settled form, so a hand-wrapped link is measured at the width reflow-calls closes it to. A chain inside a link's argument or inside the receiver is measured from the column the break puts it at and, where it trips a trigger there, breaks in the same text, placed at the indent of the row it ends up on.

A chain spanning a comment keeps its source layout, because breaking it would move the links away from the row the comment describes. A link whose break never closes keeps the chain as written, which covers an argument list already written one argument per line, one past max-args, and a multi-line string.

A chain inside an f-string or t-string replacement field is left as written whatever its width, because a line break inside one is PEP 701 syntax that fails to parse before Python 3.12, so an over-wide interpolation is left for line-overflow to report.

Configuration

KeyTypeDefaultMeaning
enabledbooltrueTurns the rule on or off.
max-linkspositive int | false2Breaks a method chain to one link per line once its link count exceeds the cap. false turns the count trigger off and leaves only the code-line-length budget.
max-shiftpositive int | 0 | false16How far past the broken chain's indent a hanging link's dot column may sit. A receiver wider than that takes the full split instead, standing alone with every link flush beneath it. 0 always takes the full split, and false lifts the cap so every chain hangs.

A three-link chain breaks at the default cap of 2 even where it fits the line. Setting max-links = false leaves code-line-length as the only trigger, and setting max-shift = 0 takes the full split for every chain.

The Canonical Case

query.filter(a).order(b).limit(c) fits on one line, yet its three calls exceed max-links. The chain breaks anyway, with query.filter(a) on the head row and .order(b) and .limit(c) hanging beneath it, each . under the head's own, so a pipeline of that length reads down one column of dots.

result = (
    query.filter(a)
         .order(b)
         .limit(c)
)
python

More Examples

The assignment target base.one().two().three().attr and the del target other.first().second().third().slot are both chains. Each breaks like any other chain, wrapped in parentheses with the calls stacked and the trailing .attr or .slot kept on its last call's row.

Python accepts a parenthesized assignment target and the output compiles, so this layout is pinned as deliberate. Leaving a store target alone would take a target flag threaded through every nested expression the rule visits.

query.filter(alpha).order(beta).limit(gamma) is passed as the value= keyword of emit. The chain breaks inside a new parenthesis pair, with query.filter(alpha) one indent step past the nested = row and .order and .limit stacked beneath its dot, and the closing )) returns to that row's own indent rather than to the column the chain opened at, so the block hangs from the statement rather than from emit's argument list.

The subscript [index] applies to the result of the whole frame.select(cols).where(cond).rows() chain rather than to any one link. The added parentheses wrap only the chain, and [index] sits past the closing ), so the stacked calls read first and the subscript reads as the one operation applied after them.

In frame.loc[key].mean().round(digits).item(), the subscript [key] comes before the first call, so it belongs to the receiver rather than counting as a link. The head row is frame.loc[key].mean(), and .round(digits) and .item() hang beneath the . that follows the subscript.

Hanging each . beneath the dot after an_extremely_long_receiver_name would put the dot column further out than max-shift allows. The chain takes the full split instead, so the receiver stands alone on its row and .filter(alpha), .order(beta), and .limit(gamma) are written flush beneath it at one indent.

No Change

obj.first(a).second(b) is written across two rows and would fit joined on one line. The rule leaves it broken, because it only ever breaks a chain. Rejoining on a fit test while breaking on a link count would flip the same chain back and forth on every run, so a chain already broken across lines keeps that layout.

No Change

The % expression is already written with its operands on two rows, and os.urandom(8).hex() sits inside the parenthesized right operand. Nothing changes, because the two-link chain sits at max-links and fits its row, measured against the settled layout of the pair around it.

The stack-method-chains dependency on reflow-parentheses declares that order, so a pair that does split at a narrower width settles before the chain inside it is measured.

No Change

The .lookup link carries the hand-wrapped compute(alpha, argument list, and reflow-calls = false turns off the rule that would close it. The chain stays as written, because a chain is measured with each link at the width its argument lists settle to, and no argument list closes while reflow-calls is off, so the .lookup link still spans lines when the chain is read.

  1. process_env_var(tokenizer.read().text.replace(".", "_")) passes a method chain as its one positional. stack-method-chains wraps the chain in parentheses and stacks .text.replace(...) under tokenizer.read(), and reflow-calls then writes the argument in keyword form as env_var = .... The parentheses stay around the value, because the value spans rows, and the stacked chain keeps its layout through the = alignment instead of being re-wrapped.