collection-built-by-statements (SMP531)

SMP531🔧 Fix sometimes availableLogicAll rules

Flags an empty dict, list or set that the statements right after it only fill with entries, which the literal could hold instead.

Why is this bad?

Filling a collection one statement at a time spreads one value over several lines, and a reader has to follow each add to learn what the collection holds. A literal shows the whole value where it is created, and the entries sit in the order they are added.

Example

Example: fix

Before

1def headers(token: str) -> dict[str, str]:
2    headers = {}
3    headers["Accept"] = "application/json"
4    headers["Cache"] = "no-store"
5    return headers

After

1def headers(token: str) -> dict[str, str]:
2    headers = {"Accept": "application/json", "Cache": "no-store"}
3    return headers

What it does

Active on

An assignment of {}, [] or set() to one name is reported when the statements right after it, one after another, each add an entry to that name: name[key] = value for a dict, name.append(value) for a list and name.add(value) for a set. The run stops at the first statement that is not such an add.

Keys and values must be constants or plain names, and must not read the collection itself.

  • A list filled with appends: the entries are written in the order they were added.

    Example: fix

    Before

    1def names(first: str, second: str) -> list[str]:
    2    out = []
    3    out.append(first)
    4    out.append(second)
    5    return out
    

    After

    1def names(first: str, second: str) -> list[str]:
    2    out = [first, second]
    3    return out
    
  • A set filled with constants: a set display takes the constants in the order given.

    Example: fix

    Before

    1def unique() -> set[int]:
    2    seen = set()
    3    seen.add(1)
    4    seen.add(2)
    5    return seen
    

    After

    1def unique() -> set[int]:
    2    seen = {1, 2}
    3    return seen
    

Exceptions

  • An entry that calls something or reads an attribute or a subscript: the call runs in its statement’s place, and moving it into the literal would reorder it against the other entries and the collection’s creation.

    Example: not reported
    1def build() -> dict[str, int]:
    2    table = {}
    3    table["k"] = compute()
    4    table["j"] = 1
    5    return table
    6
    7
    8def compute() -> int:
    9    return 1
    
  • An entry that reads the collection: the literal does not exist yet while its entries are built.

    Example: not reported
    1def build(name: str) -> dict[str, object]:
    2    table = {}
    3    table["self"] = table
    4    table[name] = 1
    5    return table
    
  • A statement between the literal and its first add: the run is empty, so nothing is reported.

    Example: not reported
    1def build(log: list[str]) -> dict[str, int]:
    2    table = {}
    3    log.append("start")
    4    table["b"] = 2
    5    return table
    
  • Two keys, or two set elements, that are the same: the second add replaces the first, which a literal would show as a repeated key that Ruff reports. Values that hash alike count as the same, such as 1 and True, and so do two mentions of one name.

    Example: not reported
    1def build() -> dict[str, int]:
    2    table = {}
    3    table["k"] = 1
    4    table["k"] = 2
    5    return table
    
    Example: not reported
    1def build() -> set[object]:
    2    seen = set()
    3    seen.add(1)
    4    seen.add(True)
    5    return seen
    
  • An annotated assignment: the literal would need the annotation, which the rule does not rewrite.

    Example: not reported
    1def build() -> dict[str, int]:
    2    table: dict[str, int] = {}
    3    table["k"] = 1
    4    return table
    
  • A comment between two adds: the comment would lose the statement it stands beside.

    Example: not reported
    1def build() -> dict[str, int]:
    2    table = {}
    3    table["a"] = 1
    4    # the second key
    5    table["b"] = 2
    6    return table
    
  • A list that Ruff’s FURB113 reports: with Ruff’s preview mode on and FURB113 selected, Ruff reports two or more appends to a list, even with appends to other lists between them, and this rule leaves that list to Ruff.

    Example: not reported

    pyproject.toml

    1[tool.ruff.lint]
    2preview = true
    3select = ["FURB113"]
    
    1def build() -> list[int]:
    2    result = []
    3    result.append(1)
    4    result.append(2)
    5    return result
    

Fix

The fix replaces the run with one literal of the same kind and keeps the assignment’s name. A tuple used as a key or an entry is parenthesized, since a comma would end it.

The fix is safe when every key and value is a constant. Building such a literal cannot raise, so it stops at the same point as the statements. A name may be undefined or hold an unhashable value, and then the statements would leave the collection partly filled while the literal leaves it unbound. Those runs are fixed as unsafe.

Example: fix

Before

1def keyed(name: str, value: int) -> dict[str, int]:
2    result = {}
3    result[name] = value
4    return result

After

1def keyed(name: str, value: int) -> dict[str, int]:
2    result = {name: value}
3    return result

Ruff overlap

Ruff’s unnecessary-collection-call (C408) rewrites dict() and list() calls into literals, and does not look at the statements that follow them.

Other tools

  • Ruff: variation of preview rule FURB113, which merges consecutive append calls into extend and which this rule leaves to it; this rule covers dicts, sets and single appends right after an empty literal.

  • flake8-simplify reports a dictionary filled by the statements after it as SIM904. It covers dictionaries only, and it does not fix them.

  • pylint reports the same dictionaries as dict-init-mutate. It covers dictionaries only, with no fix.