Skip to content

inlinable-bindings

inlinable-bindings reports a function-local binding that is written once and read once when its value would drop into that single read at no cost, leaving the same computation in the same place on a row that still fits. A binding written once and read once is not a defect on its own, because naming an intermediate is the ordinary way an author gives a reader a handle, so the rule reports only that narrower case and leaves the inline-or-keep decision to whoever reads the finding.

The rule reads the per-Source BindingAnalysis table to count writes and reads per binding, then drops every candidate whose inline would cost something:

  • A value that already spans rows stays as written, because the replacement would carry those rows into the read.
  • A read inside a region the write sits outside of stays as written, covering a loop body, a while test, a try or with arm, an except clause naming the exception class, a nested function, a lambda body, and the per-item part of a comprehension, because the inline would change how often the value is computed, what guards it, or what a closure captures.
  • A swap that pushes the read's own row past code-line-length stays as written, because the layout rules would then break the call across rows and the file would grow for a name removed.
  • A candidate for which no replacement text resolves is withheld rather than reported bare, so every finding names the expression that would stand in the binding's place.

Those regions exempt only the read that sits inside them, so a part of the same construct that runs once, where the author wrote it, keeps its finding, and a comprehension's outermost iterable and a lambda's parameter default are both still reported.

Several kinds of binding never reach that cost test at all:

  • A binding matching the allow-pattern glob (default _*, which exempts an intentionally-unused name) stays quiet, as does a binding a later del names, because the inline would leave that del naming a value nothing bound.
  • An augmented assignment counts as both a write and a read, so a binding it targets reaches two uses.
  • A loop variable, a comprehension target, and a function parameter are bound implicitly and stay outside the rule's reach.
  • A walrus expression's own value counts as a use, so a walrus target reaches two uses wherever anything consumes it.
  • A function that declares global or nonlocal anywhere in its body is skipped whole.
  • A tuple-unpack target stays exempt when a sibling target reads more than once. Where every target reads once, the diagnostic names the subscript rewrite (batch[0] for the first target of x, y = batch) whenever the right-hand side is a plain name or attribute.

The lint never rewrites, so the diagnostic is reported and the source stays as written.

Configuration

KeyTypeDefaultMeaning
enabledbooltrueTurns the rule on or off.
allow-patternstring"_*"Binding names exempted from the lint, a glob matched against the whole name.

The default _* exempts names starting with an underscore, the Python convention for an intentionally-unused binding. A project with stricter naming can tighten the glob, and an empty pattern exempts nothing rather than everything, the same reading miscased-constants gives its own empty default.

The Canonical Case

x in basic is written once from expensive(arg) and read once in return x + 1. The rule reports the binding and names expensive(arg) as the value to inline in place of x, without rewriting the source, since the choice to inline or keep the name is left to whoever reads the finding.

def basic(arg):
    x = expensive(arg)
    return x + 1
python

More Examples

payload is written once from await get(url) and read once in return payload, inside an async def. The binding is reported the same way it would be inside a plain def, because the rule reads both function kinds as the same kind of scope, so a value that awaits is not exempt.

scaled is written once from row.value * 2 and read once in yield scaled + 1, both inside the same for body. The binding is reported, because inlining leaves the multiplication running exactly as often as it already does, so a loop exempts a read only when the write sits outside it.

rows is written once from source.fetch() and read once as the outermost iterable of the list comprehension, which Python evaluates once before the first iteration. The binding is reported, unlike a read in the comprehension body, which runs once per item.

sep is written once from ", " and read once as the default of the lambda's s parameter, which Python evaluates where the lambda is written rather than on each later call. The binding is reported, because a lambda exempts a read in its body alone.

cap is written once from compute() and read once as the default of the nested def's limit parameter, which Python evaluates where the def is written rather than on each later call. The binding is reported, because a nested function exempts a read in its body alone, the same split a lambda takes.

total is written above the match from sum(data) and read once inside one case arm, the same placement a loop or a try arm exempts. The binding is reported, because a case arm runs at most once and opens no guard the enclosing scope had not already passed, the same reading an if branch gets.

total is written above the if from sum(data) and read once inside its body, the same placement a loop or a try arm exempts. The binding is reported, because an if branch runs at most once and opens no guard the enclosing scope had not already passed, so inlining changes neither how often sum(data) runs nor what protects it.

parsed: int = int(raw) carries a type annotation, and parsed is read once in return parsed + 1. The finding names int(raw) as the value to inline, because the binding table records the value of an annotated assignment the same way it records a plain one, so the annotation changes nothing about the report.

x, y = point.coords unpacks an attribute access, and render(x, y) reads each target once. Both targets are reported, and the findings name point.coords[0] for x and point.coords[1] for y, because an attribute right-hand side takes the subscript rewrite the same way a plain name does.

first, second = batch unpacks a plain name, and combine(first, second) reads each target once. Both targets are reported, and the findings name batch[0] for first and batch[1] for second, so the whole unpacking could be replaced by subscripts, while the source is left as written.

No Change

x in basic is written once from expensive(arg) and read once in return x + 1, the case the rule reports, but the whole function sits between # fmt: off and # fmt: on. No diagnostic is emitted for anything inside the suppressed span, because the pipeline filters diagnostics by range the same way it filters edits, so a suppression silences lint output and rewrites together.

No Change

updater declares global counter, then writes next_value once from counter + 1 and reads it once in return next_value. Nothing in updater is reported, because a global declaration skips the whole function rather than the one line, since counting the uses of counter would mean following the binding out to module scope, which the rule does not do.

No Change

handle is written once from acquire() and read once in use(handle), and del handle names it again below without counting as a read. The binding is not reported, because replacing the read with acquire() would leave del handle naming a value nothing bound.

No Change

x in [x * x for x in xs] is bound by the comprehension and read only once, but it lives in the comprehension's own scope rather than squares's function scope. x is not reported, because the rule counts bindings at function scope and a comprehension target never enters that count.

No Change

tmp_value in consume is written once from compute() and read once in return tmp_value + 1, with allow-pattern = "tmp_*" set for the rule. tmp_value is not reported, because the setting replaces the default _* pattern, so any name starting with tmp_ is exempt and a name starting with an underscore no longer is.

No Change

default is written above the try from fallback or {} and read once in the finally arm, which runs on the way out of the statement whether or not the body raised. The binding is not reported, because inlining would move fallback or {} into the unwind path rather than leaving it where the author wrote it.

No Change

limit is written once above the while from compute() and read once in the loop's test, which Python re-evaluates before every pass. The binding is not reported, because replacing the read with compute() would run it once per iteration rather than once per call, the same outcome a read in the loop body gets.

No Change

ratio is written once from factor / 100 and read once inside the comprehension body, which runs once per item in values. The binding is not reported, for the same reason a read inside a loop body is not, because inlining would turn one division into one per element.

No Change

helper in factory is written once from compute() and read once inside the body of lambda x: x * helper, and the binding table attributes that read to factory's scope rather than the lambda's. The binding is not reported anyway, because the lambda body runs on each later call, so inlining would move compute() into that body and change both when it runs and what the closure captures.

No Change

cutoff is written once above the for from before or 0.0 and read once inside the loop body, the case the rule otherwise reports. The binding is not reported, because replacing the read with before or 0.0 would recompute it once per path rather than once per call.

No Change

limit is written once in factory from config.limit() and read once inside the body of the nested check, which runs on each later call rather than where the binding sits. The binding is not reported, because inlining would move config.limit() into the closure and change both when it runs and what check captures.

No Change

default is written above the try from fallback or {} and read once inside the except OSError body. The binding is not reported, because inlining would move fallback or {} under a guard written to cover the read_json call rather than the fallback.

No Change

default is written above the try from fallback or {} and read once inside the try body rather than a handler. The binding is not reported, because inlining would move fallback or {} under the guard the try opens, the same outcome a read in an except body gets.

No Change

expected is written above the try from mod.Error and read once in the except expected: clause, which Python evaluates only once a raise reaches the handler. The binding is not reported, because inlining would move mod.Error under the guard the try opens rather than leaving it where the author wrote it.

No Change

totals is written once and read once in return totals, but its value is a dict literal that already spans rows. The binding is not reported, because the replacement would carry those rows into the return and the file would grow for a name removed.

No Change

n := len(items) passes its value to print and is read nowhere else, so n reaches exactly one use and passes the count checks. n is still not reported, because a walrus target names no assignment value the rule could put in its place, and every finding names the expression that would stand in.

No Change

data is written once inside the with body from handle.read() and read once after the block closes. The binding is not reported, because replacing the read with handle.read() would run it against a file the context manager has already closed, so a guard that covers the write or the read without the other exempts the binding either way round.

No Change

total = 0 is one write, and total += value inside the for body is both a second write and a read of total. total is not reported, because a binding written twice cannot be replaced at a single read.

No Change

digest is written once from hashlib.sha256(record.encode()).hexdigest() and read once in the return, and both rows sit inside the default code-line-length of 88 columns. The binding is not reported, because replacing the read with the hashlib call would carry the return row to 93 columns, and a swap that pushes its own row past the budget is declined.

No Change

name, value = lookup() unpacks a call result, and render(name, value) reads each target once. Neither target is reported, because rewriting the reads as lookup()[0] and lookup()[1] would run the call twice, so no replacement text resolves, and the rule reports a binding only when it can name the expression that would stand in its place.

For per-line opt-outs, the Suppression chapter covers the # prose: ignore[inlinable-bindings] directive.