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())