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 chain inside an argument of a call is measured from the column it moves to when reflow-calls explodes that call, counting whatever follows it on that row, so a chain that fits there stays joined rather than stacking inside a parenthesis pair the source never had. 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. A chain inside a % template or a str.format() call that prefer-fstring converts where it lands stays joined in the same way outside a collection reflow-collections expands and a signature reflow-signatures lays out one parameter per line, since the conversion places it in a replacement field.

At a 39-column budget, advise( explodes, and the f-string prefer-fstring writes for "%s:%s" % (gamma.get(key).strip(), delta) reaches the budget exactly on the row its argument lands on. stack-method-chains leaves gamma.get(key).strip() joined inside a template reflow-calls leaves for prefer-fstring, so the chain lands in a replacement field and the template converts in the same pass.

result = advise(
    alpha_value,
    beta_value,
    f"{gamma.get(key).strip()}:{delta}"
)
python

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. At a 52-column budget, stacking the expression statement fetch(...).order_by(name).all() inside f sets the receiver fetch( on a row at indent 8, so the argument list reflow-calls explodes lands at indent 12, where the f-string prefer-fstring writes for "%s:%s" % (gamma.get(key).strip().lower(), delta) ends three columns past the budget. stack-method-chains measures the receiver's template from the indent the stack moves it to rather than the one the statement sits at, so the template stays as written and its three-link inner chain breaks under the count trigger in the same pass.

  2. At a 50-column budget, stacking fetch(...).order_by(name).all() inside a parenthesis pair sets the receiver fetch( on a row at indent 4, so the argument list reflow-calls explodes lands at indent 8, where the f-string prefer-fstring writes for "%s:%s" % (gamma.get(key).strip().lower(), delta) ends one column past the budget. stack-method-chains reads whether a template inside the receiver converts from the reflow-calls walk over the receiver where the stack lands it, so the template stays as written and its three-link inner chain breaks under the count trigger in the same pass.

  3. At a 36-column budget, stacking fetch(...).order_by(name).all() explodes fetch( at indent 8, and reflow-collections expands the operand tuple of "%s=%s" % (field.name.strip().lower(), value) at indent 12, where the 26-column field.name.strip().lower() runs two columns past the budget. stack-method-chains measures a chain inside the tuple from the row the tuple's expansion lands it on, so the chain breaks under the width trigger in the same pass rather than on the next.

  4. At a 39-column budget, advise( explodes, and the f-string prefer-fstring writes for "%s:%s" % (gamma.get(key).strip(), delta) reaches the budget exactly on the row its argument lands on. stack-method-chains leaves gamma.get(key).strip() joined inside a template reflow-calls leaves for prefer-fstring, so the chain lands in a replacement field and the template converts in the same pass.

  5. At a 50-column budget, stacking query.filter(...).order_by(name).all() sets query.filter( on a row at indent 4, so the argument list reflow-calls explodes lands at indent 8, where the f-string prefer-fstring writes for "%s:%s" % (gamma.get(key).strip().lower(), delta) ends one column past the budget. stack-method-chains reads whether a template inside a link converts from the reflow-calls walk over that link's arguments where the stack lands them, so the template stays as written and its three-link inner chain breaks under the count trigger in the same pass.

  6. advise(...) runs past the 88-column budget, so reflow-calls puts each argument on its own row one indent step in. From where it sits as written, the joined payload.get("command_name", "").lstrip("/") would overflow as well, but the chain is measured on the argument row it lands on and stays joined rather than stacking inside a parenthesis pair the source never had.

  7. int(...) crosses the 40-column budget, so reflow-calls puts its argument on its own row. The joined _os.uname().release.split(".") fits there, but the [0] after it takes the row past the budget, so the chain is measured with that trailing text counted in and breaks, rather than staying joined and leaving reflow-calls to explode split(".") around its one argument.

  8. explode_me(...) fits the 88-column budget on its own and overflows only while the joined chain trails it, so reflow-calls explodes it only if the chain stays joined. Since the chain sits behind the call rather than inside one of its arguments, it is measured from the column the source writes it at, where it overflows and breaks, and explode_me(...) then fits and keeps its arguments on one row.

  9. "{}:{}".format(alpha, beta).order_by(name).first().all() carries four links as written, but prefer-fstring rewrites "{}:{}".format(alpha, beta) as the 17-column f"{alpha}:{beta}" in the same pass. stack-method-chains reads the chain as it stands once that rewrite lands, the f-string as its receiver and .format no longer a link, so the receiver runs wider than max-shift allows and the three remaining links take the full split, which a second pass leaves unchanged.

  10. advise(flist=..., skip=hide) crosses the 42-column budget, so reflow-calls puts each keyword on its own row and align-equals writes a space on either side of each =. self.folders.listdir(a).sort() and the comma closing its row would fit right after a bare flist=, but the chain is measured past the buffered flist = and breaks, because those two columns take the row past the budget.

  11. 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.