SLD6xx - Code Complexity

SLD601: Functions limited to a weighted complexity of 30. A flat function still costs ~1 per line, so the budget reads roughly like a line count for unnested code. Each body line’s weight is

1.3 ** (indent_depth + max(0, bracket_depth - 1))

where indent_depth counts indentation past the function body’s baseline (one step = 4 spaces) and bracket_depth is the deepest stack of (, [, { opened on that line. Blank lines weigh 0; comment-only lines weigh 1 unweighted. Deep nesting and dense expressions are penalized exponentially – four levels of if/for cost roughly 2.86x per line, pushing functions toward early returns or extraction.

# Bad (passes line count, fails complexity)
def process(items):
    for item in items:
        if item.active:
            for child in item.children:
                if child.valid:
                    if check(child):
                        do_work(child)  # depth 5, weight ~3.7

# Good (guard clauses keep weights near 1.0 per line)
def process(items):
    for item in items:
        if not item.active:
            continue
        for child in item.children:
            if not child.valid or not check(child):
                continue
            do_work(child)

SLD602: Functions limited to 4 arguments (excludes self/cls)

SLD603: Classes limited to 15 methods (excludes dunder methods)

SLD604: Modules limited to 400 lines

SLD605: Flags nested with statements that can be flattened into a single contextlib.ExitStack. The check fires on a with statement whose body’s last statement is another with — every __exit__ in the chain runs at the same point at the end, so an ExitStack preserves the exact semantics with one level of indentation. Statements between the withs are fine, and anything after the whole nest (outside the outermost with) is fine; only stuff sandwiched between an inner with and the end of its enclosing with blocks the refactor, and that case is left alone.

# Bad
with acquire() as resource:
    prepared = prepare(resource)
    with process(prepared) as handle:
        do_work(handle)
print("done")

# Good
from contextlib import ExitStack

with ExitStack() as stack:
    resource = stack.enter_context(acquire())
    prepared = prepare(resource)
    handle = stack.enter_context(process(prepared))
    do_work(handle)
print("done")

SLD606: Flags try/finally statements outside of @contextlib.contextmanager (or @contextlib.asynccontextmanager) generators. try/finally is the bare-knuckles version of a context manager: the cleanup belongs behind a with either way, so either reuse an existing context manager or define one. The exemption is the natural body of a @contextmanager-decorated generator, where try/finally around yield is the conventional shape.

# Bad
def process(path):
    f = open(path)
    try:
        return f.read()
    finally:
        f.close()

# Good (reuse an existing context manager)
def process(path):
    with open(path) as f:
        return f.read()

# Good (define your own context manager)
from contextlib import contextmanager

@contextmanager
def acquire(resource):
    resource.lock()
    try:
        yield resource
    finally:
        resource.unlock()

SLD607: Flags try/except blocks whose handlers are all just pass. The intent — swallow these exceptions — lands in a single line with contextlib.suppress, and the noise of the try/except scaffolding goes away. Handlers that do real work are untouched; a mix of pass-only and real handlers is also left alone. else and finally clauses are not handled by suppress, so their presence disables the check.

# Bad
try:
    config.remove(key)
except KeyError:
    pass

# Good
from contextlib import suppress

with suppress(KeyError):
    config.remove(key)

SLD608: Dataclasses limited to 10 fields (excludes ClassVar annotations, which are class attributes rather than instance fields). A dataclass that needs more than ten fields is usually two ideas crammed into one — split it, or group related fields into a nested dataclass.

# Bad
@dataclass(frozen=True, slots=True, kw_only=True)
class Order:
    id: int
    customer_name: str
    customer_email: str
    customer_phone: str
    billing_street: str
    billing_city: str
    billing_zip: str
    shipping_street: str
    shipping_city: str
    shipping_zip: str
    total: int

# Good (group related fields into nested dataclasses)
@dataclass(frozen=True, slots=True, kw_only=True)
class Order:
    id: int
    customer: Customer
    billing: Address
    shipping: Address
    total: int

SLD609: Flags a function parameter whose only role inside the body is to select a branch – it is tested in an if, while, ternary, assert, or match and nothing else flows from its value. A function that branches on a parameter is doing two jobs under one name; lift the choice out of the parameter and up into which function the caller calls.

The analysis is intraprocedural by design. A parameter that is passed onward to a callee counts as a data use, so a pure forwarder is not flagged – the callee gets flagged on its own once it has a branching parameter, and the next run flags the forwarder once it has to choose which split to call. Attribute access (p.x), indexing (p[0]), and arithmetic (p + 1) all count as data uses too: the parameter is being consumed for its value, not directly tested. self / cls and *args / **kwargs are never reported. Parameters used only inside a nested function or lambda are conservatively uncounted (they look like closure captures, not branch tests).

Membership is treated by the right-hand side. p in ("a", "b") (a literal, or a tuple/list/set of literals) is a branch test – a finite set of compile-time choices the caller could pick between – so p is flagged. But p in haystack where haystack is a runtime value makes p a search needle: its value is consumed like an index, not tested against a fixed set, so it counts as a data use and is not flagged. == / != remain branch tests against any right-hand side. (A consequence: p in NAMED_CONSTANT is read as runtime and not flagged, and p in "literal_string" is read as a finite choice and flagged – rare enough to silence with # noqa: SLD609 when the string is really a search target.)

# Bad
def fetch_user(user_id, mark_seen):
    user = db.get(user_id)
    if mark_seen:
        user.touch()
    return user

def render(data, fmt):
    if fmt == "json":
        return to_json(data)
    return to_xml(data)

# Good (split the function)
def fetch_user(user_id):
    return db.get(user_id)

def fetch_user_and_mark(user_id):
    user = fetch_user(user_id)
    user.touch()
    return user

# Good (parameter flows into data, not just a branch)
def set_enabled(widget, enabled):
    widget.enabled = enabled

# Good (parameter is passed onward -- forwarder)
def forward(x, flag):
    return helper(x, flag)

# Good (``None``-default idiom: control AND data use of ``opts``)
def parse(text, opts=None):
    if opts is None:
        opts = default_opts()
    return run(text, opts)

SLD610: Flags for loops that iterate over range(len(...)). Building a range of indices only to subscript the sequence is the bookkeeping enumerate exists to remove: reach for enumerate(seq) when the index is genuinely needed and plain iteration when it is not. The check fires when the loop’s iterable is a range(...) call with a len(...) anywhere in its arguments, so range(len(x)), range(0, len(x)), and range(len(x) - 1) all qualify; a range(n) over a plain numeric count is left alone.

# Bad
for i in range(len(items)):
    print(i, items[i])

# Good (index wanted)
for i, item in enumerate(items):
    print(i, item)

# Good (index not wanted)
for item in items:
    print(item)

SLD611: Flags while loops that walk a manual cursor by comparing a bare index name against a len(...) count. It is the same indexing pattern as SLD610 wearing while-loop clothing – the body invariably ends in i += 1 and seq[i] – and an iterator or enumerate carries the position for you. The check fires when the test is a single ordering comparison (<, <=, >, >=) with a bare name on one side and an expression containing len(...) on the other. A worklist drained by mutation against a constant bound, such as while len(stack) > 0, is not flagged: neither side is a cursor name into the collection.

# Bad
i = 0
while i < len(items):
    print(items[i])
    i += 1

# Good
for item in items:
    print(item)

# Fine (worklist drained by mutation, not an index walk)
while len(stack) > 0:
    process(stack.pop())